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,2388 @@
|
|
|
1
|
+
"""Cognitive workspace: continuity of thought between interactions (Mind M2).
|
|
2
|
+
|
|
3
|
+
A bounded store of active concerns, each carrying a salience score that
|
|
4
|
+
events raise and time decays. When idle capacity exists the scheduler pops
|
|
5
|
+
the most salient concern and runs one bounded thinking job; the outcome
|
|
6
|
+
updates memory, resolves the concern, proposes an initiative or experiment,
|
|
7
|
+
or concludes "nothing to do" (which decays salience faster so rumination
|
|
8
|
+
cannot persist). A nightly sleep window lets the heavy standing agenda run
|
|
9
|
+
when the cluster is idle.
|
|
10
|
+
|
|
11
|
+
This is the difference between "runs phases every N hours" and "has
|
|
12
|
+
something on her mind." Generic in ColonyAI; the deployment feeds it events
|
|
13
|
+
and supplies the thinker (the LLM reasoning path).
|
|
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 sqlite3
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
|
30
|
+
|
|
31
|
+
from apsimo.scope_bounds import (
|
|
32
|
+
SUBJECT_PERSON_ID_MAX_CHARS,
|
|
33
|
+
VIEWER_SCOPE_MAX_CHARS,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
CONCERN_KINDS = ("question", "goal", "thread", "anomaly", "maintenance")
|
|
39
|
+
RECENT_RESOLUTIONS_LIMIT = 20
|
|
40
|
+
_UTC_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
41
|
+
_OWNER_RESOLUTION_PROVENANCE = "owner_api"
|
|
42
|
+
_LEGACY_RESOLUTION_PROVENANCE = "legacy_unrecorded"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConcernResolutionConflict(ValueError):
|
|
46
|
+
"""An immutable terminal concern receipt conflicts with a new claim."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolution_material(
|
|
50
|
+
*,
|
|
51
|
+
concern_id: str,
|
|
52
|
+
outcome: Optional[str],
|
|
53
|
+
note: str,
|
|
54
|
+
cascade: Optional[bool],
|
|
55
|
+
resolved_by: Optional[str],
|
|
56
|
+
resolved_at: str,
|
|
57
|
+
provenance: str,
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
if cascade is not None and type(cascade) is not bool:
|
|
60
|
+
raise ValueError("concern resolution cascade must be boolean or null")
|
|
61
|
+
note_digest = hashlib.sha256(note.encode("utf-8")).hexdigest()
|
|
62
|
+
payload = {
|
|
63
|
+
"schema": "ColonyConcernResolutionReceiptV1",
|
|
64
|
+
"version": 1,
|
|
65
|
+
"concern_id": concern_id,
|
|
66
|
+
"outcome": outcome,
|
|
67
|
+
"note": note,
|
|
68
|
+
"note_digest": note_digest,
|
|
69
|
+
"cascade": cascade,
|
|
70
|
+
"resolved_by": resolved_by,
|
|
71
|
+
"resolved_at": resolved_at,
|
|
72
|
+
"provenance": provenance,
|
|
73
|
+
}
|
|
74
|
+
encoded = json.dumps(
|
|
75
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
76
|
+
).encode("utf-8")
|
|
77
|
+
record_digest = hashlib.sha256(encoded).hexdigest()
|
|
78
|
+
return {
|
|
79
|
+
**payload,
|
|
80
|
+
"resolution_id": f"concern-resolution:{record_digest}",
|
|
81
|
+
"record_digest": record_digest,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _canonical_json(value: Any) -> str:
|
|
86
|
+
return json.dumps(
|
|
87
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cascade_source_refs(values: Any) -> List[str]:
|
|
92
|
+
if not isinstance(values, (list, tuple)):
|
|
93
|
+
raise ValueError("concern cascade sources must be a list")
|
|
94
|
+
refs: List[str] = []
|
|
95
|
+
for value in values:
|
|
96
|
+
if type(value) is not str or not value or value != value.strip():
|
|
97
|
+
raise ValueError("concern cascade source reference is invalid")
|
|
98
|
+
if len(value) > 512:
|
|
99
|
+
raise ValueError("concern cascade source reference is too long")
|
|
100
|
+
ref = value
|
|
101
|
+
if ref not in refs:
|
|
102
|
+
refs.append(ref)
|
|
103
|
+
if len(refs) > 30:
|
|
104
|
+
raise ValueError("concern cascade supports at most 30 sources")
|
|
105
|
+
return refs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cascade_intent_material(
|
|
109
|
+
*,
|
|
110
|
+
resolution_id: str,
|
|
111
|
+
concern_id: str,
|
|
112
|
+
requested: Optional[bool],
|
|
113
|
+
source_refs: List[str],
|
|
114
|
+
captured_at: str,
|
|
115
|
+
capture_provenance: str,
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
if requested is not None and type(requested) is not bool:
|
|
118
|
+
raise ValueError("cascade intent requested flag is invalid")
|
|
119
|
+
if type(resolution_id) is not str or not resolution_id:
|
|
120
|
+
raise ValueError("cascade intent resolution id is invalid")
|
|
121
|
+
if type(concern_id) is not str or not concern_id:
|
|
122
|
+
raise ValueError("cascade intent concern id is invalid")
|
|
123
|
+
if type(captured_at) is not str or not captured_at:
|
|
124
|
+
raise ValueError("cascade intent capture time is invalid")
|
|
125
|
+
if capture_provenance not in {"resolution_transaction", "migration_snapshot"}:
|
|
126
|
+
raise ValueError("cascade intent capture provenance is invalid")
|
|
127
|
+
payload = {
|
|
128
|
+
"schema": "ColonyConcernCascadeIntentV1",
|
|
129
|
+
"version": 1,
|
|
130
|
+
"resolution_id": resolution_id,
|
|
131
|
+
"concern_id": concern_id,
|
|
132
|
+
"requested": requested,
|
|
133
|
+
"source_refs": _cascade_source_refs(source_refs),
|
|
134
|
+
"captured_at": captured_at,
|
|
135
|
+
"capture_provenance": capture_provenance,
|
|
136
|
+
}
|
|
137
|
+
record_digest = hashlib.sha256(
|
|
138
|
+
_canonical_json(payload).encode("utf-8")
|
|
139
|
+
).hexdigest()
|
|
140
|
+
return {
|
|
141
|
+
**payload,
|
|
142
|
+
"intent_id": f"concern-cascade-intent:{record_digest}",
|
|
143
|
+
"record_digest": record_digest,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cascade_material(
|
|
148
|
+
*,
|
|
149
|
+
intent_id: str,
|
|
150
|
+
intent_digest: str,
|
|
151
|
+
resolution_id: str,
|
|
152
|
+
concern_id: str,
|
|
153
|
+
requested: Optional[bool],
|
|
154
|
+
source_refs: List[str],
|
|
155
|
+
source_capture: str,
|
|
156
|
+
status: str,
|
|
157
|
+
settled_sources: List[str],
|
|
158
|
+
failed_sources: List[str],
|
|
159
|
+
unexpected_result_count: int,
|
|
160
|
+
failure_codes: List[str],
|
|
161
|
+
failure_digest: Optional[str],
|
|
162
|
+
recorded_at: str,
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
if requested is not None and type(requested) is not bool:
|
|
165
|
+
raise ValueError("cascade receipt requested flag is invalid")
|
|
166
|
+
if type(intent_id) is not str or not intent_id:
|
|
167
|
+
raise ValueError("cascade receipt intent id is invalid")
|
|
168
|
+
if not re.fullmatch(r"[a-f0-9]{64}", intent_digest or ""):
|
|
169
|
+
raise ValueError("cascade receipt intent digest is invalid")
|
|
170
|
+
if type(resolution_id) is not str or not resolution_id:
|
|
171
|
+
raise ValueError("cascade receipt resolution id is invalid")
|
|
172
|
+
if type(concern_id) is not str or not concern_id:
|
|
173
|
+
raise ValueError("cascade receipt concern id is invalid")
|
|
174
|
+
if source_capture not in {"resolution_transaction", "migration_snapshot"}:
|
|
175
|
+
raise ValueError("cascade receipt source capture is invalid")
|
|
176
|
+
if type(recorded_at) is not str or not recorded_at:
|
|
177
|
+
raise ValueError("cascade receipt record time is invalid")
|
|
178
|
+
refs = _cascade_source_refs(source_refs)
|
|
179
|
+
settled = _cascade_source_refs(settled_sources)
|
|
180
|
+
failed = _cascade_source_refs(failed_sources)
|
|
181
|
+
if set(settled) & set(failed) or set(settled + failed) - set(refs):
|
|
182
|
+
raise ValueError("cascade receipt source projection is invalid")
|
|
183
|
+
if (
|
|
184
|
+
settled != [ref for ref in refs if ref in set(settled)]
|
|
185
|
+
or failed != [ref for ref in refs if ref in set(failed)]
|
|
186
|
+
):
|
|
187
|
+
raise ValueError("cascade receipt source projection is not canonical")
|
|
188
|
+
if type(unexpected_result_count) is not int or unexpected_result_count < 0:
|
|
189
|
+
raise ValueError("cascade receipt unexpected result count is invalid")
|
|
190
|
+
allowed_failure_codes = {
|
|
191
|
+
"duplicate_result", "execution_error", "malformed_result",
|
|
192
|
+
"missing_result", "not_settled", "operation_conflict",
|
|
193
|
+
"operation_unverified", "settler_error", "unknown_result",
|
|
194
|
+
}
|
|
195
|
+
if not isinstance(failure_codes, (list, tuple)) or any(
|
|
196
|
+
type(code) is not str or code not in allowed_failure_codes
|
|
197
|
+
for code in failure_codes
|
|
198
|
+
):
|
|
199
|
+
raise ValueError("cascade receipt failure codes are invalid")
|
|
200
|
+
codes = sorted(set(failure_codes))
|
|
201
|
+
allowed = {
|
|
202
|
+
"succeeded", "failed", "not_requested", "not_applicable",
|
|
203
|
+
"legacy_unknown",
|
|
204
|
+
}
|
|
205
|
+
if status not in allowed:
|
|
206
|
+
raise ValueError("cascade receipt status is invalid")
|
|
207
|
+
if status == "succeeded" and (
|
|
208
|
+
requested is not True or settled != refs or failed
|
|
209
|
+
or unexpected_result_count or codes or failure_digest is not None
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("successful cascade receipt is inconsistent")
|
|
212
|
+
if status == "failed" and (
|
|
213
|
+
requested is not True or not refs or set(settled + failed) != set(refs)
|
|
214
|
+
or not codes or not failure_digest
|
|
215
|
+
):
|
|
216
|
+
raise ValueError("failed cascade receipt is inconsistent")
|
|
217
|
+
if status == "not_requested" and requested is not False:
|
|
218
|
+
raise ValueError("not-requested cascade receipt is inconsistent")
|
|
219
|
+
if status == "not_applicable" and (requested is not True or refs):
|
|
220
|
+
raise ValueError("not-applicable cascade receipt is inconsistent")
|
|
221
|
+
if status in {"not_requested", "not_applicable", "legacy_unknown"} and (
|
|
222
|
+
settled or failed or unexpected_result_count or codes
|
|
223
|
+
or failure_digest is not None
|
|
224
|
+
):
|
|
225
|
+
raise ValueError("non-executed cascade receipt has execution evidence")
|
|
226
|
+
if failure_digest is not None and not re.fullmatch(r"[a-f0-9]{64}", failure_digest):
|
|
227
|
+
raise ValueError("cascade receipt failure digest is invalid")
|
|
228
|
+
payload = {
|
|
229
|
+
"schema": "ColonyConcernCascadeReceiptV1",
|
|
230
|
+
"version": 1,
|
|
231
|
+
"intent_id": intent_id,
|
|
232
|
+
"intent_digest": intent_digest,
|
|
233
|
+
"resolution_id": resolution_id,
|
|
234
|
+
"concern_id": concern_id,
|
|
235
|
+
"requested": requested,
|
|
236
|
+
"source_refs": refs,
|
|
237
|
+
"source_capture": source_capture,
|
|
238
|
+
"status": status,
|
|
239
|
+
"settled_sources": settled,
|
|
240
|
+
"failed_sources": failed,
|
|
241
|
+
"unexpected_result_count": unexpected_result_count,
|
|
242
|
+
"failure_codes": codes,
|
|
243
|
+
"failure_digest": failure_digest,
|
|
244
|
+
"recorded_at": recorded_at,
|
|
245
|
+
}
|
|
246
|
+
record_digest = hashlib.sha256(
|
|
247
|
+
_canonical_json(payload).encode("utf-8")
|
|
248
|
+
).hexdigest()
|
|
249
|
+
return {
|
|
250
|
+
**payload,
|
|
251
|
+
"cascade_receipt_id": f"concern-cascade:{record_digest}",
|
|
252
|
+
"record_digest": record_digest,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _ordered_event_time(value: Any) -> tuple[str, int]:
|
|
257
|
+
"""Return an exact aware timestamp and an integer ordering key."""
|
|
258
|
+
|
|
259
|
+
if type(value) is not str or not value or value != value.strip():
|
|
260
|
+
raise ValueError("external event occurred_at must be canonical text")
|
|
261
|
+
candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
262
|
+
try:
|
|
263
|
+
parsed = datetime.fromisoformat(candidate)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise ValueError("external event occurred_at is invalid") from exc
|
|
266
|
+
if parsed.tzinfo is None:
|
|
267
|
+
raise ValueError("external event occurred_at requires a timezone")
|
|
268
|
+
delta = parsed.astimezone(timezone.utc) - _UTC_EPOCH
|
|
269
|
+
microseconds = (
|
|
270
|
+
delta.days * 86_400_000_000
|
|
271
|
+
+ delta.seconds * 1_000_000
|
|
272
|
+
+ delta.microseconds
|
|
273
|
+
)
|
|
274
|
+
return value, microseconds
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def workspace_mode() -> str:
|
|
278
|
+
"""Effective COLONY_WORKSPACE: explicit env > autonomy preset > off.
|
|
279
|
+
|
|
280
|
+
An explicitly-set invalid value falls back to "off", exactly as the
|
|
281
|
+
legacy reader did; the preset only fills the unset case."""
|
|
282
|
+
from apsimo.util.autonomy_preset import resolve
|
|
283
|
+
return resolve("COLONY_WORKSPACE", ("off", "shadow", "live"), "off")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def workspace_enabled() -> bool:
|
|
287
|
+
return workspace_mode() in ("shadow", "live")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _capacity() -> int:
|
|
291
|
+
try:
|
|
292
|
+
return int(os.environ.get("COLONY_WORKSPACE_CAPACITY", "24"))
|
|
293
|
+
except ValueError:
|
|
294
|
+
return 24
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _decay_half_life_hours() -> float:
|
|
298
|
+
try:
|
|
299
|
+
return float(os.environ.get("COLONY_WORKSPACE_HALFLIFE_HOURS", "12"))
|
|
300
|
+
except ValueError:
|
|
301
|
+
return 12.0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _evict_floor() -> float:
|
|
305
|
+
try:
|
|
306
|
+
return float(os.environ.get("COLONY_WORKSPACE_EVICT_FLOOR", "0.05"))
|
|
307
|
+
except ValueError:
|
|
308
|
+
return 0.05
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _thought_budget() -> int:
|
|
312
|
+
try:
|
|
313
|
+
return int(os.environ.get("COLONY_WORKSPACE_THOUGHT_BUDGET", "8"))
|
|
314
|
+
except ValueError:
|
|
315
|
+
return 8
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _resolved_ttl_hours() -> float:
|
|
319
|
+
"""How long a resolved concern suppresses re-raising the same dedup_key.
|
|
320
|
+
|
|
321
|
+
A concern is often raised FROM a still-open source by a periodic ingest;
|
|
322
|
+
once someone resolves the concern, recreating it on the very next tick
|
|
323
|
+
makes the resolve cosmetic. Within this window the resolved row answers
|
|
324
|
+
the upsert instead. If the source is genuinely still open after the
|
|
325
|
+
window, the concern legitimately returns."""
|
|
326
|
+
try:
|
|
327
|
+
return float(os.environ.get("COLONY_WORKSPACE_RESOLVED_TTL_HOURS", "24"))
|
|
328
|
+
except ValueError:
|
|
329
|
+
return 24.0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def in_sleep_window(now: Optional[datetime] = None) -> bool:
|
|
333
|
+
"""COLONY_SLEEP_WINDOW = 'HH:MM-HH:MM' in the deployment's local time
|
|
334
|
+
(uses the process tz). Empty disables. Wrap-around (22:00-06:00) ok."""
|
|
335
|
+
win = os.environ.get("COLONY_SLEEP_WINDOW", "").strip()
|
|
336
|
+
if not win or "-" not in win:
|
|
337
|
+
return False
|
|
338
|
+
try:
|
|
339
|
+
a, b = win.split("-", 1)
|
|
340
|
+
ah, am = [int(x) for x in a.split(":")]
|
|
341
|
+
bh, bm = [int(x) for x in b.split(":")]
|
|
342
|
+
except ValueError:
|
|
343
|
+
return False
|
|
344
|
+
now = now or datetime.now()
|
|
345
|
+
cur = now.hour * 60 + now.minute
|
|
346
|
+
start, end = ah * 60 + am, bh * 60 + bm
|
|
347
|
+
if start <= end:
|
|
348
|
+
return start <= cur < end
|
|
349
|
+
return cur >= start or cur < end # wraps midnight
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class Concern:
|
|
354
|
+
concern_id: str
|
|
355
|
+
kind: str
|
|
356
|
+
summary: str
|
|
357
|
+
salience: float
|
|
358
|
+
sources: List[str] = field(default_factory=list)
|
|
359
|
+
thoughts_spent: int = 0
|
|
360
|
+
max_thoughts: int = 8
|
|
361
|
+
status: str = "active" # active | resolved | evicted
|
|
362
|
+
last_note: str = ""
|
|
363
|
+
created_at: float = 0.0
|
|
364
|
+
last_touched: float = 0.0
|
|
365
|
+
last_thought_at: Optional[float] = None
|
|
366
|
+
# memory ids the thinker actually consulted while reasoning about this
|
|
367
|
+
# concern (a measured provenance link, most-recent first) -- NOT a
|
|
368
|
+
# render-time similarity guess. Empty until it has been thought about.
|
|
369
|
+
memory_refs: List[str] = field(default_factory=list)
|
|
370
|
+
dedup_key: str = ""
|
|
371
|
+
subject_person_id: str = ""
|
|
372
|
+
viewer_scope: str = "owner"
|
|
373
|
+
shareability: str = "owner_private"
|
|
374
|
+
last_material_event_id: str = ""
|
|
375
|
+
last_material_event_seq: int = 0
|
|
376
|
+
last_material_event_at: str = ""
|
|
377
|
+
last_material_digest: str = ""
|
|
378
|
+
producer_name: str = "server"
|
|
379
|
+
producer_mode: str = "live"
|
|
380
|
+
producer_revision: str = "workspace-concern-v2"
|
|
381
|
+
promoted_at: str = ""
|
|
382
|
+
promotion_ref: str = ""
|
|
383
|
+
|
|
384
|
+
def visible_to(
|
|
385
|
+
self,
|
|
386
|
+
*,
|
|
387
|
+
viewer_person_id: str,
|
|
388
|
+
owner_person_id: str,
|
|
389
|
+
audiences: set[str] | frozenset[str] = frozenset(),
|
|
390
|
+
) -> bool:
|
|
391
|
+
"""Return whether this concern may be projected to one viewer.
|
|
392
|
+
|
|
393
|
+
This is deliberately small and deterministic. Event reduction stores
|
|
394
|
+
the scope; ranking or an LLM can never make a private concern visible.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
viewer = str(viewer_person_id or "").strip()
|
|
398
|
+
owner = str(owner_person_id or "").strip()
|
|
399
|
+
if not viewer:
|
|
400
|
+
return False
|
|
401
|
+
if owner and viewer == owner:
|
|
402
|
+
return True
|
|
403
|
+
if self.shareability == "public":
|
|
404
|
+
return "global" in audiences
|
|
405
|
+
if self.shareability == "shared":
|
|
406
|
+
return "shared" in audiences
|
|
407
|
+
if self.shareability == "subject_private":
|
|
408
|
+
return bool(self.subject_person_id and viewer == self.subject_person_id)
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
def public(self) -> Dict[str, Any]:
|
|
412
|
+
return {
|
|
413
|
+
"concern_id": self.concern_id, "kind": self.kind,
|
|
414
|
+
"summary": self.summary, "salience": round(self.salience, 4),
|
|
415
|
+
"sources": self.sources, "thoughts_spent": self.thoughts_spent,
|
|
416
|
+
"max_thoughts": self.max_thoughts, "status": self.status,
|
|
417
|
+
"last_note": self.last_note, "created_at": self.created_at,
|
|
418
|
+
"last_touched": self.last_touched,
|
|
419
|
+
"last_thought_at": self.last_thought_at,
|
|
420
|
+
"memory_refs": self.memory_refs,
|
|
421
|
+
"subject_person_id": self.subject_person_id,
|
|
422
|
+
"viewer_scope": self.viewer_scope,
|
|
423
|
+
"shareability": self.shareability,
|
|
424
|
+
"last_material_event_id": self.last_material_event_id,
|
|
425
|
+
"last_material_event_seq": self.last_material_event_seq,
|
|
426
|
+
"last_material_event_at": self.last_material_event_at,
|
|
427
|
+
"producer_name": self.producer_name,
|
|
428
|
+
"producer_mode": self.producer_mode,
|
|
429
|
+
"producer_revision": self.producer_revision,
|
|
430
|
+
"promoted_at": self.promoted_at,
|
|
431
|
+
"promotion_ref": self.promotion_ref,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class ConcernStore:
|
|
436
|
+
def __init__(self, db_path: str) -> None:
|
|
437
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
438
|
+
self._conn.row_factory = sqlite3.Row
|
|
439
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
440
|
+
self._lock = threading.Lock()
|
|
441
|
+
self._conn.executescript(
|
|
442
|
+
"""
|
|
443
|
+
CREATE TABLE IF NOT EXISTS concerns (
|
|
444
|
+
concern_id TEXT PRIMARY KEY,
|
|
445
|
+
kind TEXT NOT NULL,
|
|
446
|
+
summary TEXT NOT NULL,
|
|
447
|
+
salience REAL NOT NULL,
|
|
448
|
+
sources TEXT,
|
|
449
|
+
dedup_key TEXT,
|
|
450
|
+
thoughts_spent INTEGER DEFAULT 0,
|
|
451
|
+
max_thoughts INTEGER DEFAULT 8,
|
|
452
|
+
status TEXT DEFAULT 'active',
|
|
453
|
+
last_note TEXT,
|
|
454
|
+
created_at REAL NOT NULL,
|
|
455
|
+
last_touched REAL NOT NULL,
|
|
456
|
+
last_thought_at REAL
|
|
457
|
+
);
|
|
458
|
+
CREATE INDEX IF NOT EXISTS idx_concern_status_sal
|
|
459
|
+
ON concerns(status, salience);
|
|
460
|
+
CREATE INDEX IF NOT EXISTS idx_concern_dedup ON concerns(dedup_key);
|
|
461
|
+
CREATE TABLE IF NOT EXISTS concern_event_cursors (
|
|
462
|
+
consumer_id TEXT PRIMARY KEY,
|
|
463
|
+
last_seq INTEGER NOT NULL,
|
|
464
|
+
bootstrap_mode TEXT NOT NULL,
|
|
465
|
+
initialized_at TEXT NOT NULL,
|
|
466
|
+
updated_at TEXT NOT NULL,
|
|
467
|
+
last_error TEXT
|
|
468
|
+
);
|
|
469
|
+
CREATE TABLE IF NOT EXISTS concern_event_receipts (
|
|
470
|
+
consumer_id TEXT NOT NULL,
|
|
471
|
+
event_seq INTEGER NOT NULL,
|
|
472
|
+
event_id TEXT NOT NULL,
|
|
473
|
+
event_type TEXT NOT NULL,
|
|
474
|
+
material_digest TEXT NOT NULL,
|
|
475
|
+
disposition TEXT NOT NULL,
|
|
476
|
+
concern_id TEXT,
|
|
477
|
+
reason TEXT,
|
|
478
|
+
processed_at TEXT NOT NULL,
|
|
479
|
+
PRIMARY KEY (consumer_id, event_seq),
|
|
480
|
+
UNIQUE (consumer_id, event_id)
|
|
481
|
+
);
|
|
482
|
+
CREATE INDEX IF NOT EXISTS idx_concern_event_receipts_concern
|
|
483
|
+
ON concern_event_receipts(concern_id, event_seq);
|
|
484
|
+
CREATE TABLE IF NOT EXISTS concern_external_event_watermarks (
|
|
485
|
+
consumer_id TEXT NOT NULL,
|
|
486
|
+
dedup_key TEXT NOT NULL,
|
|
487
|
+
occurred_at TEXT NOT NULL,
|
|
488
|
+
occurred_at_us INTEGER NOT NULL,
|
|
489
|
+
operation TEXT NOT NULL,
|
|
490
|
+
material_digest TEXT NOT NULL,
|
|
491
|
+
event_id TEXT NOT NULL,
|
|
492
|
+
event_seq INTEGER NOT NULL,
|
|
493
|
+
updated_at TEXT NOT NULL,
|
|
494
|
+
PRIMARY KEY (consumer_id,dedup_key)
|
|
495
|
+
);
|
|
496
|
+
CREATE INDEX IF NOT EXISTS idx_concern_external_watermarks_consumer
|
|
497
|
+
ON concern_external_event_watermarks(consumer_id,event_seq);
|
|
498
|
+
CREATE TABLE IF NOT EXISTS concern_event_gaps (
|
|
499
|
+
gap_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
500
|
+
consumer_id TEXT NOT NULL,
|
|
501
|
+
prior_cursor INTEGER NOT NULL,
|
|
502
|
+
resumed_at INTEGER NOT NULL,
|
|
503
|
+
reason TEXT NOT NULL,
|
|
504
|
+
acknowledged_at TEXT NOT NULL
|
|
505
|
+
);
|
|
506
|
+
CREATE TABLE IF NOT EXISTS concern_settlements (
|
|
507
|
+
settlement_ref TEXT PRIMARY KEY,
|
|
508
|
+
concern_id TEXT NOT NULL UNIQUE,
|
|
509
|
+
settlement_kind TEXT NOT NULL,
|
|
510
|
+
evidence_refs TEXT NOT NULL,
|
|
511
|
+
reason TEXT NOT NULL,
|
|
512
|
+
settled_at TEXT NOT NULL
|
|
513
|
+
);
|
|
514
|
+
CREATE TABLE IF NOT EXISTS concern_resolutions (
|
|
515
|
+
resolution_id TEXT PRIMARY KEY,
|
|
516
|
+
concern_id TEXT NOT NULL UNIQUE,
|
|
517
|
+
outcome TEXT,
|
|
518
|
+
note TEXT NOT NULL,
|
|
519
|
+
note_digest TEXT NOT NULL,
|
|
520
|
+
cascade INTEGER,
|
|
521
|
+
resolved_by TEXT,
|
|
522
|
+
resolved_at TEXT NOT NULL,
|
|
523
|
+
provenance TEXT NOT NULL,
|
|
524
|
+
record_digest TEXT NOT NULL UNIQUE
|
|
525
|
+
);
|
|
526
|
+
CREATE TABLE IF NOT EXISTS concern_resolution_cascade_intents (
|
|
527
|
+
intent_id TEXT PRIMARY KEY,
|
|
528
|
+
resolution_id TEXT NOT NULL UNIQUE,
|
|
529
|
+
concern_id TEXT NOT NULL UNIQUE,
|
|
530
|
+
intent_json TEXT NOT NULL,
|
|
531
|
+
record_digest TEXT NOT NULL UNIQUE
|
|
532
|
+
);
|
|
533
|
+
CREATE TABLE IF NOT EXISTS concern_resolution_cascade_receipts (
|
|
534
|
+
cascade_receipt_id TEXT PRIMARY KEY,
|
|
535
|
+
intent_id TEXT NOT NULL UNIQUE,
|
|
536
|
+
resolution_id TEXT NOT NULL UNIQUE,
|
|
537
|
+
concern_id TEXT NOT NULL UNIQUE,
|
|
538
|
+
receipt_json TEXT NOT NULL,
|
|
539
|
+
record_digest TEXT NOT NULL UNIQUE
|
|
540
|
+
);
|
|
541
|
+
"""
|
|
542
|
+
)
|
|
543
|
+
# migration: memory_refs was added after first ship; add it to older
|
|
544
|
+
# DBs. ADD COLUMN is backward-compatible (existing rows read NULL).
|
|
545
|
+
try:
|
|
546
|
+
self._conn.execute("ALTER TABLE concerns ADD COLUMN memory_refs TEXT")
|
|
547
|
+
except sqlite3.OperationalError:
|
|
548
|
+
pass # column already present
|
|
549
|
+
for name, declaration in (
|
|
550
|
+
("subject_person_id", "TEXT"),
|
|
551
|
+
("viewer_scope", "TEXT DEFAULT 'owner'"),
|
|
552
|
+
("shareability", "TEXT DEFAULT 'owner_private'"),
|
|
553
|
+
("last_material_event_id", "TEXT"),
|
|
554
|
+
("last_material_event_seq", "INTEGER DEFAULT 0"),
|
|
555
|
+
("last_material_event_at", "TEXT"),
|
|
556
|
+
("last_material_digest", "TEXT"),
|
|
557
|
+
("producer_name", "TEXT DEFAULT 'legacy'"),
|
|
558
|
+
("producer_mode", "TEXT DEFAULT 'unknown'"),
|
|
559
|
+
("producer_revision", "TEXT DEFAULT 'unversioned'"),
|
|
560
|
+
("promoted_at", "TEXT"),
|
|
561
|
+
("promotion_ref", "TEXT"),
|
|
562
|
+
):
|
|
563
|
+
try:
|
|
564
|
+
self._conn.execute(
|
|
565
|
+
f"ALTER TABLE concerns ADD COLUMN {name} {declaration}"
|
|
566
|
+
)
|
|
567
|
+
except sqlite3.OperationalError:
|
|
568
|
+
pass
|
|
569
|
+
try:
|
|
570
|
+
self._conn.execute(
|
|
571
|
+
"ALTER TABLE concern_resolutions ADD COLUMN cascade INTEGER"
|
|
572
|
+
)
|
|
573
|
+
except sqlite3.OperationalError:
|
|
574
|
+
pass
|
|
575
|
+
self._migrate_legacy_resolutions()
|
|
576
|
+
self._migrate_resolution_cascade_records()
|
|
577
|
+
self._conn.commit()
|
|
578
|
+
|
|
579
|
+
def _migrate_legacy_resolutions(self) -> None:
|
|
580
|
+
"""Freeze truthful receipts for terminal rows predating owner receipts.
|
|
581
|
+
|
|
582
|
+
Legacy rows do not contain an exact outcome or resolver. Persisting
|
|
583
|
+
explicit NULLs keeps those facts unknown instead of letting a later
|
|
584
|
+
caller accidentally claim its request was the original decision.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
rows = self._conn.execute(
|
|
588
|
+
"""SELECT concern_id,last_note,last_touched,created_at
|
|
589
|
+
FROM concerns WHERE status='resolved'
|
|
590
|
+
AND NOT EXISTS (
|
|
591
|
+
SELECT 1 FROM concern_resolutions r
|
|
592
|
+
WHERE r.concern_id=concerns.concern_id
|
|
593
|
+
)"""
|
|
594
|
+
).fetchall()
|
|
595
|
+
for row in rows:
|
|
596
|
+
stamp = (
|
|
597
|
+
row["last_touched"]
|
|
598
|
+
if row["last_touched"] is not None
|
|
599
|
+
else row["created_at"]
|
|
600
|
+
)
|
|
601
|
+
try:
|
|
602
|
+
resolved_at = datetime.fromtimestamp(
|
|
603
|
+
float(stamp), timezone.utc,
|
|
604
|
+
).isoformat()
|
|
605
|
+
except (OverflowError, TypeError, ValueError):
|
|
606
|
+
resolved_at = _UTC_EPOCH.isoformat()
|
|
607
|
+
material = _resolution_material(
|
|
608
|
+
concern_id=str(row["concern_id"]),
|
|
609
|
+
outcome=None,
|
|
610
|
+
note=str(row["last_note"] or ""),
|
|
611
|
+
cascade=None,
|
|
612
|
+
resolved_by=None,
|
|
613
|
+
resolved_at=resolved_at,
|
|
614
|
+
provenance=_LEGACY_RESOLUTION_PROVENANCE,
|
|
615
|
+
)
|
|
616
|
+
self._conn.execute(
|
|
617
|
+
"""INSERT OR IGNORE INTO concern_resolutions
|
|
618
|
+
(resolution_id,concern_id,outcome,note,note_digest,
|
|
619
|
+
cascade,resolved_by,resolved_at,provenance,record_digest)
|
|
620
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
621
|
+
(
|
|
622
|
+
material["resolution_id"], material["concern_id"],
|
|
623
|
+
material["outcome"], material["note"],
|
|
624
|
+
material["note_digest"], material["cascade"],
|
|
625
|
+
material["resolved_by"], material["resolved_at"],
|
|
626
|
+
material["provenance"], material["record_digest"],
|
|
627
|
+
),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
@staticmethod
|
|
631
|
+
def _stored_source_refs(value: Any) -> List[str]:
|
|
632
|
+
try:
|
|
633
|
+
decoded = json.loads(value or "[]")
|
|
634
|
+
except (TypeError, ValueError) as exc:
|
|
635
|
+
raise ValueError("concern sources are invalid") from exc
|
|
636
|
+
return _cascade_source_refs(decoded)
|
|
637
|
+
|
|
638
|
+
def _insert_cascade_intent(self, intent: Mapping[str, Any]) -> None:
|
|
639
|
+
self._conn.execute(
|
|
640
|
+
"""INSERT INTO concern_resolution_cascade_intents
|
|
641
|
+
(intent_id,resolution_id,concern_id,intent_json,record_digest)
|
|
642
|
+
VALUES (?,?,?,?,?)""",
|
|
643
|
+
(
|
|
644
|
+
intent["intent_id"], intent["resolution_id"],
|
|
645
|
+
intent["concern_id"], _canonical_json(intent),
|
|
646
|
+
intent["record_digest"],
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def _insert_cascade_receipt(self, receipt: Mapping[str, Any]) -> None:
|
|
651
|
+
self._conn.execute(
|
|
652
|
+
"""INSERT INTO concern_resolution_cascade_receipts
|
|
653
|
+
(cascade_receipt_id,intent_id,resolution_id,concern_id,
|
|
654
|
+
receipt_json,record_digest)
|
|
655
|
+
VALUES (?,?,?,?,?,?)""",
|
|
656
|
+
(
|
|
657
|
+
receipt["cascade_receipt_id"], receipt["intent_id"],
|
|
658
|
+
receipt["resolution_id"], receipt["concern_id"],
|
|
659
|
+
_canonical_json(receipt), receipt["record_digest"],
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def _migrate_resolution_cascade_records(self) -> None:
|
|
664
|
+
"""Make prior terminal rows explicit without inventing outcomes.
|
|
665
|
+
|
|
666
|
+
An intent created here is marked as a migration-time source snapshot,
|
|
667
|
+
then receives a terminal ``legacy_unknown`` receipt unless the base
|
|
668
|
+
decision proves cascade was not requested. An existing transactional
|
|
669
|
+
intent without a terminal receipt is deliberately left pending: that
|
|
670
|
+
shape means a current-version process stopped between its decision and
|
|
671
|
+
source-settlement phases.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
rows = self._conn.execute(
|
|
675
|
+
"""SELECT r.*,c.sources AS concern_sources
|
|
676
|
+
FROM concern_resolutions r
|
|
677
|
+
LEFT JOIN concerns c ON c.concern_id=r.concern_id
|
|
678
|
+
ORDER BY r.resolved_at,r.resolution_id"""
|
|
679
|
+
).fetchall()
|
|
680
|
+
for row in rows:
|
|
681
|
+
base = self._resolution_receipt(row)
|
|
682
|
+
intent_row = self._conn.execute(
|
|
683
|
+
"""SELECT * FROM concern_resolution_cascade_intents
|
|
684
|
+
WHERE resolution_id=?""",
|
|
685
|
+
(base["resolution_id"],),
|
|
686
|
+
).fetchone()
|
|
687
|
+
if intent_row is None:
|
|
688
|
+
refs = self._stored_source_refs(row["concern_sources"])
|
|
689
|
+
intent = _cascade_intent_material(
|
|
690
|
+
resolution_id=base["resolution_id"],
|
|
691
|
+
concern_id=base["concern_id"],
|
|
692
|
+
requested=base["cascade"],
|
|
693
|
+
source_refs=refs,
|
|
694
|
+
captured_at=datetime.now(timezone.utc).isoformat(),
|
|
695
|
+
capture_provenance="migration_snapshot",
|
|
696
|
+
)
|
|
697
|
+
self._insert_cascade_intent(intent)
|
|
698
|
+
else:
|
|
699
|
+
intent = self._cascade_intent_receipt(intent_row, base)
|
|
700
|
+
|
|
701
|
+
terminal = self._conn.execute(
|
|
702
|
+
"""SELECT * FROM concern_resolution_cascade_receipts
|
|
703
|
+
WHERE intent_id=?""",
|
|
704
|
+
(intent["intent_id"],),
|
|
705
|
+
).fetchone()
|
|
706
|
+
if terminal is not None:
|
|
707
|
+
self._cascade_receipt(terminal, base, intent)
|
|
708
|
+
continue
|
|
709
|
+
if intent["capture_provenance"] != "migration_snapshot":
|
|
710
|
+
continue
|
|
711
|
+
status = (
|
|
712
|
+
"not_requested" if intent["requested"] is False
|
|
713
|
+
else "legacy_unknown"
|
|
714
|
+
)
|
|
715
|
+
receipt = _cascade_material(
|
|
716
|
+
intent_id=intent["intent_id"],
|
|
717
|
+
intent_digest=intent["record_digest"],
|
|
718
|
+
resolution_id=base["resolution_id"],
|
|
719
|
+
concern_id=base["concern_id"],
|
|
720
|
+
requested=intent["requested"],
|
|
721
|
+
source_refs=intent["source_refs"],
|
|
722
|
+
source_capture=intent["capture_provenance"],
|
|
723
|
+
status=status,
|
|
724
|
+
settled_sources=[],
|
|
725
|
+
failed_sources=[],
|
|
726
|
+
unexpected_result_count=0,
|
|
727
|
+
failure_codes=[],
|
|
728
|
+
failure_digest=None,
|
|
729
|
+
recorded_at=datetime.now(timezone.utc).isoformat(),
|
|
730
|
+
)
|
|
731
|
+
self._insert_cascade_receipt(receipt)
|
|
732
|
+
|
|
733
|
+
def _row(self, r: sqlite3.Row) -> Concern:
|
|
734
|
+
keys = r.keys()
|
|
735
|
+
mrefs = json.loads(r["memory_refs"] or "[]") if "memory_refs" in keys else []
|
|
736
|
+
return Concern(
|
|
737
|
+
concern_id=r["concern_id"], kind=r["kind"], summary=r["summary"],
|
|
738
|
+
salience=r["salience"], sources=json.loads(r["sources"] or "[]"),
|
|
739
|
+
thoughts_spent=r["thoughts_spent"] or 0,
|
|
740
|
+
max_thoughts=r["max_thoughts"] or 8, status=r["status"],
|
|
741
|
+
last_note=r["last_note"] or "", created_at=r["created_at"],
|
|
742
|
+
last_touched=r["last_touched"], last_thought_at=r["last_thought_at"],
|
|
743
|
+
memory_refs=mrefs,
|
|
744
|
+
dedup_key=(r["dedup_key"] or "") if "dedup_key" in keys else "",
|
|
745
|
+
subject_person_id=(r["subject_person_id"] or "")
|
|
746
|
+
if "subject_person_id" in keys else "",
|
|
747
|
+
viewer_scope=(r["viewer_scope"] or "owner")
|
|
748
|
+
if "viewer_scope" in keys else "owner",
|
|
749
|
+
shareability=(r["shareability"] or "owner_private")
|
|
750
|
+
if "shareability" in keys else "owner_private",
|
|
751
|
+
last_material_event_id=(r["last_material_event_id"] or "")
|
|
752
|
+
if "last_material_event_id" in keys else "",
|
|
753
|
+
last_material_event_seq=int(r["last_material_event_seq"] or 0)
|
|
754
|
+
if "last_material_event_seq" in keys else 0,
|
|
755
|
+
last_material_event_at=(r["last_material_event_at"] or "")
|
|
756
|
+
if "last_material_event_at" in keys else "",
|
|
757
|
+
last_material_digest=(r["last_material_digest"] or "")
|
|
758
|
+
if "last_material_digest" in keys else "",
|
|
759
|
+
producer_name=(r["producer_name"] or "legacy")
|
|
760
|
+
if "producer_name" in keys else "legacy",
|
|
761
|
+
producer_mode=(r["producer_mode"] or "unknown")
|
|
762
|
+
if "producer_mode" in keys else "unknown",
|
|
763
|
+
producer_revision=(r["producer_revision"] or "unversioned")
|
|
764
|
+
if "producer_revision" in keys else "unversioned",
|
|
765
|
+
promoted_at=(r["promoted_at"] or "")
|
|
766
|
+
if "promoted_at" in keys else "",
|
|
767
|
+
promotion_ref=(r["promotion_ref"] or "")
|
|
768
|
+
if "promotion_ref" in keys else "")
|
|
769
|
+
|
|
770
|
+
def upsert(self, *, kind: str, summary: str, salience: float,
|
|
771
|
+
dedup_key: str, sources: List[str],
|
|
772
|
+
max_thoughts: int,
|
|
773
|
+
producer_name: str = "server",
|
|
774
|
+
producer_mode: str = "live",
|
|
775
|
+
producer_revision: str = "workspace-concern-v2") -> Concern:
|
|
776
|
+
producer = str(producer_name or "unknown").strip()[:64]
|
|
777
|
+
mode = str(producer_mode or "unknown").strip().lower()[:16]
|
|
778
|
+
revision = str(producer_revision or "unversioned").strip()[:128]
|
|
779
|
+
if mode not in {"off", "shadow", "live", "unknown"}:
|
|
780
|
+
mode = "unknown"
|
|
781
|
+
now = time.time()
|
|
782
|
+
with self._lock:
|
|
783
|
+
r = self._conn.execute(
|
|
784
|
+
"SELECT * FROM concerns WHERE dedup_key=? AND status='active'",
|
|
785
|
+
(dedup_key,)).fetchone()
|
|
786
|
+
if r is not None:
|
|
787
|
+
merged = list(dict.fromkeys(
|
|
788
|
+
json.loads(r["sources"] or "[]") + sources))[:30]
|
|
789
|
+
new_sal = min(1.0, max(r["salience"], salience) + 0.05)
|
|
790
|
+
self._conn.execute(
|
|
791
|
+
"UPDATE concerns SET salience=?, sources=?, "
|
|
792
|
+
"last_touched=?,producer_name=?,producer_mode=?,"
|
|
793
|
+
"producer_revision=?,promoted_at=NULL,promotion_ref=NULL "
|
|
794
|
+
"WHERE concern_id=?",
|
|
795
|
+
(new_sal, json.dumps(merged), now, producer, mode,
|
|
796
|
+
revision, r["concern_id"]))
|
|
797
|
+
self._conn.commit()
|
|
798
|
+
return self._row(self._conn.execute(
|
|
799
|
+
"SELECT * FROM concerns WHERE concern_id=?",
|
|
800
|
+
(r["concern_id"],)).fetchone())
|
|
801
|
+
# Recently-resolved same key: return the resolved row untouched
|
|
802
|
+
# instead of minting a fresh concern, so a resolve sticks even
|
|
803
|
+
# while the underlying source is still open (see _resolved_ttl).
|
|
804
|
+
r2 = self._conn.execute(
|
|
805
|
+
"SELECT * FROM concerns WHERE dedup_key=? AND "
|
|
806
|
+
"status='resolved' ORDER BY last_touched DESC LIMIT 1",
|
|
807
|
+
(dedup_key,)).fetchone()
|
|
808
|
+
if r2 is not None and (now - (r2["last_touched"] or 0)) <\
|
|
809
|
+
_resolved_ttl_hours() * 3600.0:
|
|
810
|
+
return self._row(r2)
|
|
811
|
+
cid = f"c-{uuid.uuid4().hex[:12]}"
|
|
812
|
+
self._conn.execute(
|
|
813
|
+
"INSERT INTO concerns (concern_id,kind,summary,salience,"
|
|
814
|
+
"sources,dedup_key,max_thoughts,created_at,last_touched,"
|
|
815
|
+
"producer_name,producer_mode,producer_revision)"
|
|
816
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
817
|
+
(cid, kind, summary, min(1.0, salience), json.dumps(sources),
|
|
818
|
+
dedup_key, max_thoughts, now, now, producer, mode, revision))
|
|
819
|
+
self._conn.commit()
|
|
820
|
+
return self._row(self._conn.execute(
|
|
821
|
+
"SELECT * FROM concerns WHERE concern_id=?", (cid,)).fetchone())
|
|
822
|
+
|
|
823
|
+
def event_cursor(self, consumer_id: str) -> Optional[int]:
|
|
824
|
+
with self._lock:
|
|
825
|
+
row = self._conn.execute(
|
|
826
|
+
"SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
|
|
827
|
+
(consumer_id,),
|
|
828
|
+
).fetchone()
|
|
829
|
+
return int(row["last_seq"]) if row is not None else None
|
|
830
|
+
|
|
831
|
+
def initialize_event_cursor(
|
|
832
|
+
self,
|
|
833
|
+
consumer_id: str,
|
|
834
|
+
sequence: int,
|
|
835
|
+
*,
|
|
836
|
+
bootstrap_mode: str,
|
|
837
|
+
) -> int:
|
|
838
|
+
"""Create a durable reducer cursor without overwriting an existing one."""
|
|
839
|
+
|
|
840
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
841
|
+
with self._lock:
|
|
842
|
+
self._conn.execute(
|
|
843
|
+
"""INSERT OR IGNORE INTO concern_event_cursors
|
|
844
|
+
(consumer_id,last_seq,bootstrap_mode,initialized_at,updated_at,last_error)
|
|
845
|
+
VALUES (?,?,?,?,?,NULL)""",
|
|
846
|
+
(consumer_id, max(0, int(sequence)), bootstrap_mode, now, now),
|
|
847
|
+
)
|
|
848
|
+
self._conn.commit()
|
|
849
|
+
row = self._conn.execute(
|
|
850
|
+
"SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
|
|
851
|
+
(consumer_id,),
|
|
852
|
+
).fetchone()
|
|
853
|
+
return int(row["last_seq"])
|
|
854
|
+
|
|
855
|
+
def set_event_error(self, consumer_id: str, message: str) -> None:
|
|
856
|
+
with self._lock:
|
|
857
|
+
self._conn.execute(
|
|
858
|
+
"UPDATE concern_event_cursors SET last_error=?, updated_at=? "
|
|
859
|
+
"WHERE consumer_id=?",
|
|
860
|
+
(str(message or "")[:200], datetime.now(timezone.utc).isoformat(),
|
|
861
|
+
consumer_id),
|
|
862
|
+
)
|
|
863
|
+
self._conn.commit()
|
|
864
|
+
|
|
865
|
+
def acknowledge_event_gap(
|
|
866
|
+
self,
|
|
867
|
+
consumer_id: str,
|
|
868
|
+
*,
|
|
869
|
+
prior_cursor: int,
|
|
870
|
+
resume_after: int,
|
|
871
|
+
reason: str,
|
|
872
|
+
) -> int:
|
|
873
|
+
"""Audit and advance across journal records lost to retention.
|
|
874
|
+
|
|
875
|
+
This is never automatic under the default stop policy. It preserves
|
|
876
|
+
the gap as evidence instead of pretending the missing records were
|
|
877
|
+
processed.
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
881
|
+
with self._lock:
|
|
882
|
+
current = self._conn.execute(
|
|
883
|
+
"SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
|
|
884
|
+
(consumer_id,),
|
|
885
|
+
).fetchone()
|
|
886
|
+
if current is None or int(current["last_seq"]) != int(prior_cursor):
|
|
887
|
+
raise ValueError("event cursor changed before gap acknowledgement")
|
|
888
|
+
if int(resume_after) <= int(prior_cursor):
|
|
889
|
+
raise ValueError("gap resume cursor must advance")
|
|
890
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
891
|
+
try:
|
|
892
|
+
self._conn.execute(
|
|
893
|
+
"""INSERT INTO concern_event_gaps
|
|
894
|
+
(consumer_id,prior_cursor,resumed_at,reason,acknowledged_at)
|
|
895
|
+
VALUES (?,?,?,?,?)""",
|
|
896
|
+
(consumer_id, int(prior_cursor), int(resume_after),
|
|
897
|
+
str(reason or "journal_retention_gap")[:200], now),
|
|
898
|
+
)
|
|
899
|
+
self._conn.execute(
|
|
900
|
+
"""UPDATE concern_event_cursors
|
|
901
|
+
SET last_seq=?,updated_at=?,last_error=NULL
|
|
902
|
+
WHERE consumer_id=?""",
|
|
903
|
+
(int(resume_after), now, consumer_id),
|
|
904
|
+
)
|
|
905
|
+
self._conn.commit()
|
|
906
|
+
except Exception:
|
|
907
|
+
self._conn.rollback()
|
|
908
|
+
raise
|
|
909
|
+
return int(resume_after)
|
|
910
|
+
|
|
911
|
+
def event_reducer_status(self, consumer_id: str) -> Dict[str, Any]:
|
|
912
|
+
with self._lock:
|
|
913
|
+
cursor = self._conn.execute(
|
|
914
|
+
"SELECT * FROM concern_event_cursors WHERE consumer_id=?",
|
|
915
|
+
(consumer_id,),
|
|
916
|
+
).fetchone()
|
|
917
|
+
rows = self._conn.execute(
|
|
918
|
+
"""SELECT disposition,COUNT(*) AS n,MAX(event_seq) AS last_seq
|
|
919
|
+
FROM concern_event_receipts WHERE consumer_id=?
|
|
920
|
+
GROUP BY disposition""",
|
|
921
|
+
(consumer_id,),
|
|
922
|
+
).fetchall()
|
|
923
|
+
gaps = self._conn.execute(
|
|
924
|
+
"SELECT COUNT(*) AS n,MAX(resumed_at) AS last_resume "
|
|
925
|
+
"FROM concern_event_gaps WHERE consumer_id=?",
|
|
926
|
+
(consumer_id,),
|
|
927
|
+
).fetchone()
|
|
928
|
+
watermarks = self._conn.execute(
|
|
929
|
+
"SELECT COUNT(*) AS n,MAX(event_seq) AS last_seq "
|
|
930
|
+
"FROM concern_external_event_watermarks WHERE consumer_id=?",
|
|
931
|
+
(consumer_id,),
|
|
932
|
+
).fetchone()
|
|
933
|
+
latest_watermark = self._conn.execute(
|
|
934
|
+
"SELECT occurred_at FROM concern_external_event_watermarks "
|
|
935
|
+
"WHERE consumer_id=? ORDER BY occurred_at_us DESC LIMIT 1",
|
|
936
|
+
(consumer_id,),
|
|
937
|
+
).fetchone()
|
|
938
|
+
return {
|
|
939
|
+
"initialized": cursor is not None,
|
|
940
|
+
"cursor": int(cursor["last_seq"]) if cursor is not None else None,
|
|
941
|
+
"bootstrap_mode": cursor["bootstrap_mode"] if cursor is not None else None,
|
|
942
|
+
"initialized_at": cursor["initialized_at"] if cursor is not None else None,
|
|
943
|
+
"updated_at": cursor["updated_at"] if cursor is not None else None,
|
|
944
|
+
"last_error": cursor["last_error"] if cursor is not None else None,
|
|
945
|
+
"gaps": {
|
|
946
|
+
"count": int(gaps["n"] or 0),
|
|
947
|
+
"last_resume": int(gaps["last_resume"] or 0),
|
|
948
|
+
},
|
|
949
|
+
"event_time_watermarks": {
|
|
950
|
+
"count": int(watermarks["n"] or 0),
|
|
951
|
+
"last_event_seq": int(watermarks["last_seq"] or 0),
|
|
952
|
+
"latest_occurred_at": (
|
|
953
|
+
latest_watermark["occurred_at"]
|
|
954
|
+
if latest_watermark is not None else None
|
|
955
|
+
),
|
|
956
|
+
},
|
|
957
|
+
"dispositions": {
|
|
958
|
+
row["disposition"]: {
|
|
959
|
+
"count": int(row["n"]),
|
|
960
|
+
"last_seq": int(row["last_seq"]),
|
|
961
|
+
}
|
|
962
|
+
for row in rows
|
|
963
|
+
},
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
def apply_event(
|
|
967
|
+
self,
|
|
968
|
+
*,
|
|
969
|
+
consumer_id: str,
|
|
970
|
+
event_seq: int,
|
|
971
|
+
event_id: str,
|
|
972
|
+
event_type: str,
|
|
973
|
+
material_digest: str,
|
|
974
|
+
projection: Optional[Mapping[str, Any]],
|
|
975
|
+
skip_reason: str = "",
|
|
976
|
+
) -> Dict[str, Any]:
|
|
977
|
+
"""Atomically reduce one journal event and advance its durable cursor.
|
|
978
|
+
|
|
979
|
+
A receipt, concern mutation, and cursor update share one transaction.
|
|
980
|
+
Replaying after any crash therefore cannot duplicate a concern or
|
|
981
|
+
inflate salience for the same material state.
|
|
982
|
+
"""
|
|
983
|
+
|
|
984
|
+
sequence = int(event_seq)
|
|
985
|
+
if sequence < 1:
|
|
986
|
+
raise ValueError("event sequence must be positive")
|
|
987
|
+
consumer = str(consumer_id or "").strip()[:128]
|
|
988
|
+
native_id = str(event_id or "").strip()[:128]
|
|
989
|
+
native_type = str(event_type or "unknown").strip()[:128]
|
|
990
|
+
digest = str(material_digest or "").strip()[:128]
|
|
991
|
+
if not consumer or not native_id or not native_type or not digest:
|
|
992
|
+
raise ValueError("event authority fields are required")
|
|
993
|
+
|
|
994
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
995
|
+
with self._lock:
|
|
996
|
+
existing = self._conn.execute(
|
|
997
|
+
"""SELECT event_seq,event_type,material_digest,disposition,concern_id
|
|
998
|
+
FROM concern_event_receipts
|
|
999
|
+
WHERE consumer_id=? AND event_id=?""",
|
|
1000
|
+
(consumer, native_id),
|
|
1001
|
+
).fetchone()
|
|
1002
|
+
if existing is not None:
|
|
1003
|
+
if (int(existing["event_seq"]) != sequence
|
|
1004
|
+
or existing["event_type"] != native_type
|
|
1005
|
+
or existing["material_digest"] != digest):
|
|
1006
|
+
raise ValueError("event id was replayed with conflicting content")
|
|
1007
|
+
current = self._conn.execute(
|
|
1008
|
+
"SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
|
|
1009
|
+
(consumer,),
|
|
1010
|
+
).fetchone()
|
|
1011
|
+
return {
|
|
1012
|
+
"status": "duplicate_event",
|
|
1013
|
+
"disposition": existing["disposition"],
|
|
1014
|
+
"concern_id": existing["concern_id"],
|
|
1015
|
+
"cursor": int(current["last_seq"]) if current is not None else None,
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
cursor_row = self._conn.execute(
|
|
1019
|
+
"SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
|
|
1020
|
+
(consumer,),
|
|
1021
|
+
).fetchone()
|
|
1022
|
+
if cursor_row is None:
|
|
1023
|
+
raise ValueError("event reducer cursor is not initialized")
|
|
1024
|
+
cursor = int(cursor_row["last_seq"])
|
|
1025
|
+
if sequence != cursor + 1:
|
|
1026
|
+
raise ValueError("event sequence is not cursor-contiguous")
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
1030
|
+
disposition = "skipped"
|
|
1031
|
+
concern_id: Optional[str] = None
|
|
1032
|
+
reason = str(skip_reason or "")[:100]
|
|
1033
|
+
|
|
1034
|
+
if projection is not None:
|
|
1035
|
+
external_projection = (
|
|
1036
|
+
projection.get("external_event_projection") is True
|
|
1037
|
+
)
|
|
1038
|
+
operation_raw = projection.get("operation") or "upsert"
|
|
1039
|
+
dedup_raw = projection.get("dedup_key") or ""
|
|
1040
|
+
if external_projection:
|
|
1041
|
+
if (
|
|
1042
|
+
type(operation_raw) is not str
|
|
1043
|
+
or operation_raw not in {"upsert", "resolve"}
|
|
1044
|
+
or type(dedup_raw) is not str
|
|
1045
|
+
or not dedup_raw
|
|
1046
|
+
or dedup_raw != dedup_raw.strip()
|
|
1047
|
+
or len(dedup_raw) > 200
|
|
1048
|
+
or projection.get("producer_name")
|
|
1049
|
+
!= "external_event_concerns"
|
|
1050
|
+
):
|
|
1051
|
+
raise ValueError(
|
|
1052
|
+
"external event projection marker is invalid"
|
|
1053
|
+
)
|
|
1054
|
+
operation = str(operation_raw)
|
|
1055
|
+
dedup_key = str(dedup_raw)[:200]
|
|
1056
|
+
if not dedup_key:
|
|
1057
|
+
raise ValueError("event projection requires a dedup key")
|
|
1058
|
+
row = self._conn.execute(
|
|
1059
|
+
"SELECT * FROM concerns WHERE dedup_key=? AND status='active'",
|
|
1060
|
+
(dedup_key,),
|
|
1061
|
+
).fetchone()
|
|
1062
|
+
|
|
1063
|
+
apply_projection = True
|
|
1064
|
+
advance_watermark = False
|
|
1065
|
+
occurred_at = str(
|
|
1066
|
+
projection.get("occurred_at") or ""
|
|
1067
|
+
)[:64]
|
|
1068
|
+
occurred_at_us = 0
|
|
1069
|
+
if external_projection:
|
|
1070
|
+
occurred_at, occurred_at_us = _ordered_event_time(
|
|
1071
|
+
projection.get("occurred_at")
|
|
1072
|
+
)
|
|
1073
|
+
if len(occurred_at) > 64:
|
|
1074
|
+
raise ValueError(
|
|
1075
|
+
"external event occurred_at exceeds 64 characters"
|
|
1076
|
+
)
|
|
1077
|
+
watermark = self._conn.execute(
|
|
1078
|
+
"SELECT * FROM concern_external_event_watermarks "
|
|
1079
|
+
"WHERE consumer_id=? AND dedup_key=?",
|
|
1080
|
+
(consumer, dedup_key),
|
|
1081
|
+
).fetchone()
|
|
1082
|
+
if watermark is None:
|
|
1083
|
+
advance_watermark = True
|
|
1084
|
+
elif occurred_at_us < int(watermark["occurred_at_us"]):
|
|
1085
|
+
disposition = "external_stale_event"
|
|
1086
|
+
reason = "external_event_time_older_than_watermark"
|
|
1087
|
+
apply_projection = False
|
|
1088
|
+
elif occurred_at_us == int(watermark["occurred_at_us"]):
|
|
1089
|
+
if (
|
|
1090
|
+
operation == watermark["operation"]
|
|
1091
|
+
and digest == watermark["material_digest"]
|
|
1092
|
+
):
|
|
1093
|
+
disposition = "duplicate_material"
|
|
1094
|
+
reason = "external_event_time_equal_material_replay"
|
|
1095
|
+
else:
|
|
1096
|
+
disposition = "external_event_time_conflict"
|
|
1097
|
+
reason = (
|
|
1098
|
+
"external_event_time_equal_to_watermark_conflict"
|
|
1099
|
+
)
|
|
1100
|
+
apply_projection = False
|
|
1101
|
+
else:
|
|
1102
|
+
advance_watermark = True
|
|
1103
|
+
if not apply_projection:
|
|
1104
|
+
reference = row
|
|
1105
|
+
if reference is None:
|
|
1106
|
+
reference = self._conn.execute(
|
|
1107
|
+
"SELECT concern_id FROM concerns "
|
|
1108
|
+
"WHERE dedup_key=? ORDER BY last_touched DESC "
|
|
1109
|
+
"LIMIT 1",
|
|
1110
|
+
(dedup_key,),
|
|
1111
|
+
).fetchone()
|
|
1112
|
+
concern_id = (
|
|
1113
|
+
reference["concern_id"]
|
|
1114
|
+
if reference is not None else None
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
if apply_projection and operation == "resolve":
|
|
1118
|
+
if row is None:
|
|
1119
|
+
disposition = "resolve_noop"
|
|
1120
|
+
else:
|
|
1121
|
+
concern_id = row["concern_id"]
|
|
1122
|
+
self._conn.execute(
|
|
1123
|
+
"""UPDATE concerns SET status='resolved',salience=0.0,
|
|
1124
|
+
last_note=?,last_touched=?,last_material_event_id=?,
|
|
1125
|
+
last_material_event_seq=?,last_material_event_at=?,
|
|
1126
|
+
last_material_digest=? WHERE concern_id=?""",
|
|
1127
|
+
(str(projection.get("note") or "resolved by source event")[:500],
|
|
1128
|
+
time.time(), native_id, sequence,
|
|
1129
|
+
occurred_at, digest, concern_id),
|
|
1130
|
+
)
|
|
1131
|
+
disposition = "resolved"
|
|
1132
|
+
elif apply_projection and operation == "upsert":
|
|
1133
|
+
summary = str(projection.get("summary") or native_type)[:300]
|
|
1134
|
+
kind = str(projection.get("kind") or "thread")
|
|
1135
|
+
kind = kind if kind in CONCERN_KINDS else "thread"
|
|
1136
|
+
salience = max(0.0, min(1.0, float(
|
|
1137
|
+
projection.get("salience", 0.5))))
|
|
1138
|
+
raw_sources = projection.get("sources") or []
|
|
1139
|
+
if external_projection:
|
|
1140
|
+
if not isinstance(raw_sources, (list, tuple)):
|
|
1141
|
+
raise ValueError(
|
|
1142
|
+
"external concern sources must be a sequence"
|
|
1143
|
+
)
|
|
1144
|
+
for value in raw_sources:
|
|
1145
|
+
if (
|
|
1146
|
+
type(value) is not str
|
|
1147
|
+
or not value
|
|
1148
|
+
or value != value.strip()
|
|
1149
|
+
or len(value) > 200
|
|
1150
|
+
):
|
|
1151
|
+
raise ValueError(
|
|
1152
|
+
"external concern source is not lossless"
|
|
1153
|
+
)
|
|
1154
|
+
sources = list(dict.fromkeys(raw_sources))[:30]
|
|
1155
|
+
else:
|
|
1156
|
+
sources = list(dict.fromkeys(
|
|
1157
|
+
str(value)[:200]
|
|
1158
|
+
for value in raw_sources
|
|
1159
|
+
if str(value or "").strip()
|
|
1160
|
+
))[:30]
|
|
1161
|
+
max_thoughts = max(1, min(100, int(
|
|
1162
|
+
projection.get("max_thoughts") or _thought_budget())))
|
|
1163
|
+
subject = str(
|
|
1164
|
+
projection.get("subject_person_id") or ""
|
|
1165
|
+
)
|
|
1166
|
+
viewer_scope = str(
|
|
1167
|
+
projection.get("viewer_scope") or "owner"
|
|
1168
|
+
)
|
|
1169
|
+
if len(subject) > SUBJECT_PERSON_ID_MAX_CHARS:
|
|
1170
|
+
raise ValueError("concern subject exceeds safe bound")
|
|
1171
|
+
if (
|
|
1172
|
+
not viewer_scope
|
|
1173
|
+
or len(viewer_scope) > VIEWER_SCOPE_MAX_CHARS
|
|
1174
|
+
):
|
|
1175
|
+
raise ValueError("concern viewer scope exceeds safe bound")
|
|
1176
|
+
shareability = str(
|
|
1177
|
+
projection.get("shareability") or "owner_private")
|
|
1178
|
+
if shareability not in {
|
|
1179
|
+
"owner_private", "subject_private", "shared", "public",
|
|
1180
|
+
}:
|
|
1181
|
+
raise ValueError("invalid concern shareability")
|
|
1182
|
+
producer = str(
|
|
1183
|
+
projection.get("producer_name") or "event_concerns"
|
|
1184
|
+
)[:64]
|
|
1185
|
+
producer_mode = str(
|
|
1186
|
+
projection.get("producer_mode") or "unknown"
|
|
1187
|
+
).strip().lower()[:16]
|
|
1188
|
+
if producer_mode not in {"off", "shadow", "live", "unknown"}:
|
|
1189
|
+
producer_mode = "unknown"
|
|
1190
|
+
producer_revision = str(
|
|
1191
|
+
projection.get("producer_revision")
|
|
1192
|
+
or "event-concern-reducer-v2"
|
|
1193
|
+
)[:128]
|
|
1194
|
+
|
|
1195
|
+
if row is not None:
|
|
1196
|
+
concern_id = row["concern_id"]
|
|
1197
|
+
prior_digest = (row["last_material_digest"] or "")\
|
|
1198
|
+
if "last_material_digest" in row.keys() else ""
|
|
1199
|
+
if prior_digest == digest:
|
|
1200
|
+
disposition = "duplicate_material"
|
|
1201
|
+
else:
|
|
1202
|
+
merged = list(dict.fromkeys(
|
|
1203
|
+
json.loads(row["sources"] or "[]") + sources
|
|
1204
|
+
))[:30]
|
|
1205
|
+
new_salience = min(
|
|
1206
|
+
1.0, max(float(row["salience"]), salience) + 0.05,
|
|
1207
|
+
)
|
|
1208
|
+
self._conn.execute(
|
|
1209
|
+
"""UPDATE concerns SET kind=?,summary=?,salience=?,
|
|
1210
|
+
sources=?,last_touched=?,subject_person_id=?,
|
|
1211
|
+
viewer_scope=?,shareability=?,
|
|
1212
|
+
last_material_event_id=?,last_material_event_seq=?,
|
|
1213
|
+
last_material_event_at=?,last_material_digest=?,
|
|
1214
|
+
producer_name=?,producer_mode=?,producer_revision=?,
|
|
1215
|
+
promoted_at=NULL,promotion_ref=NULL
|
|
1216
|
+
WHERE concern_id=?""",
|
|
1217
|
+
(kind, summary, new_salience, json.dumps(merged),
|
|
1218
|
+
time.time(), subject, viewer_scope, shareability,
|
|
1219
|
+
native_id, sequence, occurred_at, digest,
|
|
1220
|
+
producer, producer_mode, producer_revision,
|
|
1221
|
+
concern_id),
|
|
1222
|
+
)
|
|
1223
|
+
disposition = "updated"
|
|
1224
|
+
else:
|
|
1225
|
+
resolved = self._conn.execute(
|
|
1226
|
+
"""SELECT * FROM concerns WHERE dedup_key=?
|
|
1227
|
+
AND status='resolved'
|
|
1228
|
+
ORDER BY last_touched DESC LIMIT 1""",
|
|
1229
|
+
(dedup_key,),
|
|
1230
|
+
).fetchone()
|
|
1231
|
+
within_ttl = resolved is not None and (
|
|
1232
|
+
time.time() - float(resolved["last_touched"] or 0)
|
|
1233
|
+
) < _resolved_ttl_hours() * 3600.0
|
|
1234
|
+
if within_ttl and not external_projection:
|
|
1235
|
+
concern_id = resolved["concern_id"]
|
|
1236
|
+
disposition = "suppressed_resolved"
|
|
1237
|
+
else:
|
|
1238
|
+
concern_id = f"c-{uuid.uuid4().hex[:12]}"
|
|
1239
|
+
touched = time.time()
|
|
1240
|
+
self._conn.execute(
|
|
1241
|
+
"""INSERT INTO concerns
|
|
1242
|
+
(concern_id,kind,summary,salience,sources,
|
|
1243
|
+
dedup_key,max_thoughts,created_at,last_touched,
|
|
1244
|
+
subject_person_id,viewer_scope,shareability,
|
|
1245
|
+
last_material_event_id,last_material_event_seq,
|
|
1246
|
+
last_material_event_at,last_material_digest,
|
|
1247
|
+
producer_name,producer_mode,producer_revision)
|
|
1248
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
1249
|
+
(concern_id, kind, summary, salience,
|
|
1250
|
+
json.dumps(sources), dedup_key, max_thoughts,
|
|
1251
|
+
touched, touched, subject, viewer_scope,
|
|
1252
|
+
shareability, native_id, sequence, occurred_at,
|
|
1253
|
+
digest, producer, producer_mode,
|
|
1254
|
+
producer_revision),
|
|
1255
|
+
)
|
|
1256
|
+
disposition = (
|
|
1257
|
+
"reopened"
|
|
1258
|
+
if external_projection and resolved is not None
|
|
1259
|
+
else "created"
|
|
1260
|
+
)
|
|
1261
|
+
elif apply_projection:
|
|
1262
|
+
raise ValueError("unsupported event projection operation")
|
|
1263
|
+
|
|
1264
|
+
if external_projection and advance_watermark:
|
|
1265
|
+
self._conn.execute(
|
|
1266
|
+
"""INSERT INTO concern_external_event_watermarks
|
|
1267
|
+
(consumer_id,dedup_key,occurred_at,occurred_at_us,
|
|
1268
|
+
operation,material_digest,event_id,event_seq,updated_at)
|
|
1269
|
+
VALUES (?,?,?,?,?,?,?,?,?)
|
|
1270
|
+
ON CONFLICT(consumer_id,dedup_key) DO UPDATE SET
|
|
1271
|
+
occurred_at=excluded.occurred_at,
|
|
1272
|
+
occurred_at_us=excluded.occurred_at_us,
|
|
1273
|
+
operation=excluded.operation,
|
|
1274
|
+
material_digest=excluded.material_digest,
|
|
1275
|
+
event_id=excluded.event_id,
|
|
1276
|
+
event_seq=excluded.event_seq,
|
|
1277
|
+
updated_at=excluded.updated_at""",
|
|
1278
|
+
(
|
|
1279
|
+
consumer, dedup_key, occurred_at, occurred_at_us,
|
|
1280
|
+
operation, digest, native_id, sequence, now_iso,
|
|
1281
|
+
),
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
self._conn.execute(
|
|
1285
|
+
"""INSERT INTO concern_event_receipts
|
|
1286
|
+
(consumer_id,event_seq,event_id,event_type,material_digest,
|
|
1287
|
+
disposition,concern_id,reason,processed_at)
|
|
1288
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
1289
|
+
(consumer, sequence, native_id, native_type, digest,
|
|
1290
|
+
disposition, concern_id, reason, now_iso),
|
|
1291
|
+
)
|
|
1292
|
+
self._conn.execute(
|
|
1293
|
+
"""UPDATE concern_event_cursors
|
|
1294
|
+
SET last_seq=?,updated_at=?,last_error=NULL
|
|
1295
|
+
WHERE consumer_id=?""",
|
|
1296
|
+
(sequence, now_iso, consumer),
|
|
1297
|
+
)
|
|
1298
|
+
self._conn.commit()
|
|
1299
|
+
except Exception:
|
|
1300
|
+
self._conn.rollback()
|
|
1301
|
+
raise
|
|
1302
|
+
|
|
1303
|
+
return {
|
|
1304
|
+
"status": "processed",
|
|
1305
|
+
"disposition": disposition,
|
|
1306
|
+
"concern_id": concern_id,
|
|
1307
|
+
"cursor": sequence,
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
def promote_concern(
|
|
1311
|
+
self,
|
|
1312
|
+
concern_id: str,
|
|
1313
|
+
*,
|
|
1314
|
+
expected_material_digest: str,
|
|
1315
|
+
promotion_ref: str,
|
|
1316
|
+
now: Optional[datetime] = None,
|
|
1317
|
+
) -> Concern:
|
|
1318
|
+
"""Explicitly promote one immutable shadow material version to live.
|
|
1319
|
+
|
|
1320
|
+
The source mode remains visible; promotion is a separate attestation.
|
|
1321
|
+
Exact replay is idempotent and a changed digest/ref is rejected.
|
|
1322
|
+
"""
|
|
1323
|
+
|
|
1324
|
+
reference = str(promotion_ref or "").strip()[:256]
|
|
1325
|
+
expected = str(expected_material_digest or "").strip()[:128]
|
|
1326
|
+
if not reference or not expected:
|
|
1327
|
+
raise ValueError("promotion reference and material digest are required")
|
|
1328
|
+
observed = now or datetime.now(timezone.utc)
|
|
1329
|
+
if observed.tzinfo is None:
|
|
1330
|
+
observed = observed.replace(tzinfo=timezone.utc)
|
|
1331
|
+
promoted = observed.astimezone(timezone.utc).isoformat()
|
|
1332
|
+
with self._lock:
|
|
1333
|
+
row = self._conn.execute(
|
|
1334
|
+
"SELECT * FROM concerns WHERE concern_id=?", (concern_id,),
|
|
1335
|
+
).fetchone()
|
|
1336
|
+
if row is None:
|
|
1337
|
+
raise ValueError("unknown concern")
|
|
1338
|
+
material = str(row["last_material_digest"] or "")
|
|
1339
|
+
if material != expected:
|
|
1340
|
+
raise ValueError("concern material changed before promotion")
|
|
1341
|
+
prior_ref = str(row["promotion_ref"] or "")
|
|
1342
|
+
if prior_ref:
|
|
1343
|
+
if prior_ref != reference:
|
|
1344
|
+
raise ValueError("concern already has a different promotion")
|
|
1345
|
+
return self._row(row)
|
|
1346
|
+
updated = self._conn.execute(
|
|
1347
|
+
"UPDATE concerns SET promoted_at=?,promotion_ref=? "
|
|
1348
|
+
"WHERE concern_id=? AND last_material_digest=? "
|
|
1349
|
+
"AND (promotion_ref IS NULL OR promotion_ref='')",
|
|
1350
|
+
(promoted, reference, concern_id, expected),
|
|
1351
|
+
)
|
|
1352
|
+
self._conn.commit()
|
|
1353
|
+
current = self._conn.execute(
|
|
1354
|
+
"SELECT * FROM concerns WHERE concern_id=?", (concern_id,),
|
|
1355
|
+
).fetchone()
|
|
1356
|
+
if (
|
|
1357
|
+
updated.rowcount != 1
|
|
1358
|
+
or current is None
|
|
1359
|
+
or str(current["last_material_digest"] or "") != expected
|
|
1360
|
+
or str(current["promotion_ref"] or "") != reference
|
|
1361
|
+
):
|
|
1362
|
+
raise ValueError("concern changed during promotion")
|
|
1363
|
+
return self._row(current)
|
|
1364
|
+
|
|
1365
|
+
def active(self, limit: int = 100) -> List[Concern]:
|
|
1366
|
+
with self._lock:
|
|
1367
|
+
rows = self._conn.execute(
|
|
1368
|
+
"SELECT * FROM concerns WHERE status='active'"
|
|
1369
|
+
" ORDER BY salience DESC LIMIT ?", (limit,)).fetchall()
|
|
1370
|
+
return [self._row(r) for r in rows]
|
|
1371
|
+
|
|
1372
|
+
@staticmethod
|
|
1373
|
+
def _producer_mode_exclusions(
|
|
1374
|
+
values: Iterable[tuple[str, str]],
|
|
1375
|
+
) -> tuple[tuple[str, str], ...]:
|
|
1376
|
+
return tuple(dict.fromkeys(
|
|
1377
|
+
(
|
|
1378
|
+
str(producer or "legacy").strip()[:64],
|
|
1379
|
+
str(mode or "unknown").strip().lower()[:16],
|
|
1380
|
+
)
|
|
1381
|
+
for producer, mode in values
|
|
1382
|
+
if str(producer or "").strip() and str(mode or "").strip()
|
|
1383
|
+
))
|
|
1384
|
+
|
|
1385
|
+
def active_for_maintenance(
|
|
1386
|
+
self,
|
|
1387
|
+
*,
|
|
1388
|
+
excluded_producer_modes: Iterable[tuple[str, str]] = (),
|
|
1389
|
+
limit: int = 500,
|
|
1390
|
+
) -> List[Concern]:
|
|
1391
|
+
exclusions = self._producer_mode_exclusions(excluded_producer_modes)
|
|
1392
|
+
where = "status='active'"
|
|
1393
|
+
params: List[Any] = []
|
|
1394
|
+
if exclusions:
|
|
1395
|
+
terms = " OR ".join(
|
|
1396
|
+
"(COALESCE(producer_name,'legacy')=? AND "
|
|
1397
|
+
"COALESCE(producer_mode,'unknown')=?)"
|
|
1398
|
+
for _ in exclusions
|
|
1399
|
+
)
|
|
1400
|
+
where += f" AND NOT ({terms})"
|
|
1401
|
+
for producer, mode in exclusions:
|
|
1402
|
+
params.extend((producer, mode))
|
|
1403
|
+
params.append(max(1, min(int(limit), 500)))
|
|
1404
|
+
with self._lock:
|
|
1405
|
+
rows = self._conn.execute(
|
|
1406
|
+
f"SELECT * FROM concerns WHERE {where} "
|
|
1407
|
+
"ORDER BY salience DESC LIMIT ?",
|
|
1408
|
+
params,
|
|
1409
|
+
).fetchall()
|
|
1410
|
+
return [self._row(row) for row in rows]
|
|
1411
|
+
|
|
1412
|
+
def active_without_producer(
|
|
1413
|
+
self, producer_name: str, *, limit: int = 100,
|
|
1414
|
+
) -> List[Concern]:
|
|
1415
|
+
"""Return active rows while excluding one producer at query time.
|
|
1416
|
+
|
|
1417
|
+
This avoids a fixed-window starvation bug without loading an
|
|
1418
|
+
unbounded number of held rows into memory. The helper is generic;
|
|
1419
|
+
schedulers retain ownership of which producer, if any, is ineligible.
|
|
1420
|
+
"""
|
|
1421
|
+
|
|
1422
|
+
return self.active_without_producers((producer_name,), limit=limit)
|
|
1423
|
+
|
|
1424
|
+
def active_without_producers(
|
|
1425
|
+
self, producer_names: Iterable[str], *, limit: int = 100,
|
|
1426
|
+
) -> List[Concern]:
|
|
1427
|
+
"""Return active rows excluding a bounded set of held producers."""
|
|
1428
|
+
|
|
1429
|
+
values = (
|
|
1430
|
+
(producer_names,)
|
|
1431
|
+
if isinstance(producer_names, str)
|
|
1432
|
+
else producer_names
|
|
1433
|
+
)
|
|
1434
|
+
producers = tuple(dict.fromkeys(
|
|
1435
|
+
str(value or "").strip()[:64]
|
|
1436
|
+
for value in values
|
|
1437
|
+
if str(value or "").strip()
|
|
1438
|
+
))
|
|
1439
|
+
if not producers:
|
|
1440
|
+
return self.active(limit=limit)
|
|
1441
|
+
bounded = max(1, min(int(limit), 500))
|
|
1442
|
+
marks = ",".join("?" for _ in producers)
|
|
1443
|
+
with self._lock:
|
|
1444
|
+
rows = self._conn.execute(
|
|
1445
|
+
"SELECT * FROM concerns WHERE status='active' "
|
|
1446
|
+
f"AND (producer_name IS NULL OR producer_name NOT IN ({marks})) "
|
|
1447
|
+
"ORDER BY salience DESC LIMIT ?",
|
|
1448
|
+
(*producers, bounded),
|
|
1449
|
+
).fetchall()
|
|
1450
|
+
return [self._row(row) for row in rows]
|
|
1451
|
+
|
|
1452
|
+
def active_for_viewer(
|
|
1453
|
+
self,
|
|
1454
|
+
*,
|
|
1455
|
+
viewer_person_id: str,
|
|
1456
|
+
owner_person_id: str,
|
|
1457
|
+
audiences: set[str] | frozenset[str] = frozenset(),
|
|
1458
|
+
limit: int = 100,
|
|
1459
|
+
) -> List[Concern]:
|
|
1460
|
+
# Fetch beyond the requested cap so private rows do not starve visible
|
|
1461
|
+
# rows below them. Workspace capacity is bounded, so this remains
|
|
1462
|
+
# cheap and avoids encoding policy into SQL fragments.
|
|
1463
|
+
rows = self.active(limit=max(_capacity(), limit, 200))
|
|
1464
|
+
return [
|
|
1465
|
+
concern for concern in rows
|
|
1466
|
+
if concern.visible_to(
|
|
1467
|
+
viewer_person_id=viewer_person_id,
|
|
1468
|
+
owner_person_id=owner_person_id,
|
|
1469
|
+
audiences=audiences,
|
|
1470
|
+
)
|
|
1471
|
+
][:limit]
|
|
1472
|
+
|
|
1473
|
+
def get(self, concern_id: str) -> Optional[Concern]:
|
|
1474
|
+
with self._lock:
|
|
1475
|
+
r = self._conn.execute(
|
|
1476
|
+
"SELECT * FROM concerns WHERE concern_id=?",
|
|
1477
|
+
(concern_id,)).fetchone()
|
|
1478
|
+
return self._row(r) if r else None
|
|
1479
|
+
|
|
1480
|
+
def set_salience(self, concern_id: str, salience: float) -> None:
|
|
1481
|
+
with self._lock:
|
|
1482
|
+
self._conn.execute(
|
|
1483
|
+
"UPDATE concerns SET salience=?, last_touched=? "
|
|
1484
|
+
"WHERE concern_id=?",
|
|
1485
|
+
(max(0.0, min(1.0, salience)), time.time(), concern_id))
|
|
1486
|
+
self._conn.commit()
|
|
1487
|
+
|
|
1488
|
+
def record_thought(self, concern_id: str, note: str, *,
|
|
1489
|
+
resolved: bool, salience: float) -> None:
|
|
1490
|
+
now = time.time()
|
|
1491
|
+
with self._lock:
|
|
1492
|
+
self._conn.execute(
|
|
1493
|
+
"UPDATE concerns SET thoughts_spent=thoughts_spent+1,"
|
|
1494
|
+
" last_note=?, last_thought_at=?, last_touched=?,"
|
|
1495
|
+
" salience=?, status=? WHERE concern_id=?",
|
|
1496
|
+
(note[:500], now, now, max(0.0, min(1.0, salience)),
|
|
1497
|
+
"resolved" if resolved else "active", concern_id))
|
|
1498
|
+
self._conn.commit()
|
|
1499
|
+
|
|
1500
|
+
@staticmethod
|
|
1501
|
+
def _resolution_receipt(row: sqlite3.Row) -> Dict[str, Any]:
|
|
1502
|
+
stored_cascade = row["cascade"]
|
|
1503
|
+
if stored_cascade is None:
|
|
1504
|
+
cascade = None
|
|
1505
|
+
elif type(stored_cascade) is int and stored_cascade in (0, 1):
|
|
1506
|
+
cascade = bool(stored_cascade)
|
|
1507
|
+
else:
|
|
1508
|
+
raise ValueError("concern resolution receipt cascade is invalid")
|
|
1509
|
+
receipt = {
|
|
1510
|
+
"schema": "ColonyConcernResolutionReceiptV1",
|
|
1511
|
+
"version": 1,
|
|
1512
|
+
"resolution_id": row["resolution_id"],
|
|
1513
|
+
"record_digest": row["record_digest"],
|
|
1514
|
+
"concern_id": row["concern_id"],
|
|
1515
|
+
"outcome": row["outcome"],
|
|
1516
|
+
"note": row["note"],
|
|
1517
|
+
"note_digest": row["note_digest"],
|
|
1518
|
+
"cascade": cascade,
|
|
1519
|
+
"resolved_by": row["resolved_by"],
|
|
1520
|
+
"resolved_at": row["resolved_at"],
|
|
1521
|
+
"provenance": row["provenance"],
|
|
1522
|
+
}
|
|
1523
|
+
expected = _resolution_material(
|
|
1524
|
+
concern_id=receipt["concern_id"],
|
|
1525
|
+
outcome=receipt["outcome"],
|
|
1526
|
+
note=receipt["note"],
|
|
1527
|
+
cascade=receipt["cascade"],
|
|
1528
|
+
resolved_by=receipt["resolved_by"],
|
|
1529
|
+
resolved_at=receipt["resolved_at"],
|
|
1530
|
+
provenance=receipt["provenance"],
|
|
1531
|
+
)
|
|
1532
|
+
if any(
|
|
1533
|
+
receipt[name] != expected[name]
|
|
1534
|
+
for name in ("note_digest", "record_digest", "resolution_id")
|
|
1535
|
+
):
|
|
1536
|
+
raise ValueError("concern resolution receipt integrity check failed")
|
|
1537
|
+
return receipt
|
|
1538
|
+
|
|
1539
|
+
@staticmethod
|
|
1540
|
+
def _cascade_intent_receipt(
|
|
1541
|
+
row: sqlite3.Row,
|
|
1542
|
+
base: Mapping[str, Any],
|
|
1543
|
+
) -> Dict[str, Any]:
|
|
1544
|
+
try:
|
|
1545
|
+
payload = json.loads(row["intent_json"])
|
|
1546
|
+
if type(payload) is not dict:
|
|
1547
|
+
raise ValueError
|
|
1548
|
+
expected = _cascade_intent_material(
|
|
1549
|
+
resolution_id=payload["resolution_id"],
|
|
1550
|
+
concern_id=payload["concern_id"],
|
|
1551
|
+
requested=payload["requested"],
|
|
1552
|
+
source_refs=payload["source_refs"],
|
|
1553
|
+
captured_at=payload["captured_at"],
|
|
1554
|
+
capture_provenance=payload["capture_provenance"],
|
|
1555
|
+
)
|
|
1556
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1557
|
+
raise ValueError("concern cascade intent integrity check failed") from exc
|
|
1558
|
+
if (
|
|
1559
|
+
set(payload) != set(expected)
|
|
1560
|
+
or payload != expected
|
|
1561
|
+
or row["intent_id"] != expected["intent_id"]
|
|
1562
|
+
or row["resolution_id"] != expected["resolution_id"]
|
|
1563
|
+
or row["concern_id"] != expected["concern_id"]
|
|
1564
|
+
or row["record_digest"] != expected["record_digest"]
|
|
1565
|
+
or payload["resolution_id"] != base["resolution_id"]
|
|
1566
|
+
or payload["concern_id"] != base["concern_id"]
|
|
1567
|
+
or payload["requested"] is not base["cascade"]
|
|
1568
|
+
):
|
|
1569
|
+
raise ValueError("concern cascade intent integrity check failed")
|
|
1570
|
+
return payload
|
|
1571
|
+
|
|
1572
|
+
@staticmethod
|
|
1573
|
+
def _cascade_receipt(
|
|
1574
|
+
row: sqlite3.Row,
|
|
1575
|
+
base: Mapping[str, Any],
|
|
1576
|
+
intent: Mapping[str, Any],
|
|
1577
|
+
) -> Dict[str, Any]:
|
|
1578
|
+
try:
|
|
1579
|
+
payload = json.loads(row["receipt_json"])
|
|
1580
|
+
if type(payload) is not dict:
|
|
1581
|
+
raise ValueError
|
|
1582
|
+
expected = _cascade_material(
|
|
1583
|
+
intent_id=payload["intent_id"],
|
|
1584
|
+
intent_digest=payload["intent_digest"],
|
|
1585
|
+
resolution_id=payload["resolution_id"],
|
|
1586
|
+
concern_id=payload["concern_id"],
|
|
1587
|
+
requested=payload["requested"],
|
|
1588
|
+
source_refs=payload["source_refs"],
|
|
1589
|
+
source_capture=payload["source_capture"],
|
|
1590
|
+
status=payload["status"],
|
|
1591
|
+
settled_sources=payload["settled_sources"],
|
|
1592
|
+
failed_sources=payload["failed_sources"],
|
|
1593
|
+
unexpected_result_count=payload["unexpected_result_count"],
|
|
1594
|
+
failure_codes=payload["failure_codes"],
|
|
1595
|
+
failure_digest=payload["failure_digest"],
|
|
1596
|
+
recorded_at=payload["recorded_at"],
|
|
1597
|
+
)
|
|
1598
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1599
|
+
raise ValueError("concern cascade receipt integrity check failed") from exc
|
|
1600
|
+
linkage = {
|
|
1601
|
+
"intent_id": intent["intent_id"],
|
|
1602
|
+
"intent_digest": intent["record_digest"],
|
|
1603
|
+
"resolution_id": base["resolution_id"],
|
|
1604
|
+
"concern_id": base["concern_id"],
|
|
1605
|
+
"requested": intent["requested"],
|
|
1606
|
+
"source_refs": intent["source_refs"],
|
|
1607
|
+
"source_capture": intent["capture_provenance"],
|
|
1608
|
+
}
|
|
1609
|
+
if (
|
|
1610
|
+
set(payload) != set(expected)
|
|
1611
|
+
or payload != expected
|
|
1612
|
+
or any(payload[name] != value for name, value in linkage.items())
|
|
1613
|
+
or row["cascade_receipt_id"] != expected["cascade_receipt_id"]
|
|
1614
|
+
or row["intent_id"] != expected["intent_id"]
|
|
1615
|
+
or row["resolution_id"] != expected["resolution_id"]
|
|
1616
|
+
or row["concern_id"] != expected["concern_id"]
|
|
1617
|
+
or row["record_digest"] != expected["record_digest"]
|
|
1618
|
+
):
|
|
1619
|
+
raise ValueError("concern cascade receipt integrity check failed")
|
|
1620
|
+
return payload
|
|
1621
|
+
|
|
1622
|
+
def _cascade_records(
|
|
1623
|
+
self,
|
|
1624
|
+
base: Mapping[str, Any],
|
|
1625
|
+
) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
|
1626
|
+
intent_row = self._conn.execute(
|
|
1627
|
+
"""SELECT * FROM concern_resolution_cascade_intents
|
|
1628
|
+
WHERE resolution_id=?""",
|
|
1629
|
+
(base["resolution_id"],),
|
|
1630
|
+
).fetchone()
|
|
1631
|
+
if intent_row is None:
|
|
1632
|
+
raise ValueError("concern cascade intent is missing")
|
|
1633
|
+
intent = self._cascade_intent_receipt(intent_row, base)
|
|
1634
|
+
terminal_row = self._conn.execute(
|
|
1635
|
+
"""SELECT * FROM concern_resolution_cascade_receipts
|
|
1636
|
+
WHERE intent_id=?""",
|
|
1637
|
+
(intent["intent_id"],),
|
|
1638
|
+
).fetchone()
|
|
1639
|
+
terminal = (
|
|
1640
|
+
self._cascade_receipt(terminal_row, base, intent)
|
|
1641
|
+
if terminal_row is not None else None
|
|
1642
|
+
)
|
|
1643
|
+
return intent, terminal
|
|
1644
|
+
|
|
1645
|
+
@staticmethod
|
|
1646
|
+
def _attach_cascade_evidence(
|
|
1647
|
+
base: Mapping[str, Any],
|
|
1648
|
+
intent: Mapping[str, Any],
|
|
1649
|
+
terminal: Optional[Mapping[str, Any]],
|
|
1650
|
+
) -> Dict[str, Any]:
|
|
1651
|
+
receipt = dict(base)
|
|
1652
|
+
if terminal is not None:
|
|
1653
|
+
receipt["cascade_evidence"] = dict(terminal)
|
|
1654
|
+
return receipt
|
|
1655
|
+
if not (
|
|
1656
|
+
base["provenance"] == _OWNER_RESOLUTION_PROVENANCE
|
|
1657
|
+
and base["cascade"] is True
|
|
1658
|
+
and intent["requested"] is True
|
|
1659
|
+
and intent["source_refs"]
|
|
1660
|
+
and intent["capture_provenance"] == "resolution_transaction"
|
|
1661
|
+
):
|
|
1662
|
+
raise ValueError("concern cascade terminal receipt is missing")
|
|
1663
|
+
receipt["cascade_evidence"] = {
|
|
1664
|
+
"schema": "ColonyConcernCascadeStateV1",
|
|
1665
|
+
"version": 1,
|
|
1666
|
+
"intent_id": intent["intent_id"],
|
|
1667
|
+
"intent_digest": intent["record_digest"],
|
|
1668
|
+
"resolution_id": base["resolution_id"],
|
|
1669
|
+
"concern_id": base["concern_id"],
|
|
1670
|
+
"requested": True,
|
|
1671
|
+
"source_refs": list(intent["source_refs"]),
|
|
1672
|
+
"source_capture": intent["capture_provenance"],
|
|
1673
|
+
"status": "pending",
|
|
1674
|
+
"started_at": intent["captured_at"],
|
|
1675
|
+
}
|
|
1676
|
+
return receipt
|
|
1677
|
+
|
|
1678
|
+
def get_resolution(self, concern_id: str) -> Optional[Dict[str, Any]]:
|
|
1679
|
+
"""Return the immutable decision and its validated cascade projection."""
|
|
1680
|
+
|
|
1681
|
+
with self._lock:
|
|
1682
|
+
row = self._conn.execute(
|
|
1683
|
+
"SELECT * FROM concern_resolutions WHERE concern_id=?",
|
|
1684
|
+
(concern_id,),
|
|
1685
|
+
).fetchone()
|
|
1686
|
+
if row is None:
|
|
1687
|
+
return None
|
|
1688
|
+
base = self._resolution_receipt(row)
|
|
1689
|
+
intent, terminal = self._cascade_records(base)
|
|
1690
|
+
return self._attach_cascade_evidence(base, intent, terminal)
|
|
1691
|
+
|
|
1692
|
+
def recent_resolutions(
|
|
1693
|
+
self, limit: int = RECENT_RESOLUTIONS_LIMIT,
|
|
1694
|
+
) -> List[Dict[str, Any]]:
|
|
1695
|
+
"""Return a bounded, integrity-checked newest-first receipt list."""
|
|
1696
|
+
|
|
1697
|
+
bounded = max(0, min(int(limit), RECENT_RESOLUTIONS_LIMIT))
|
|
1698
|
+
if bounded == 0:
|
|
1699
|
+
return []
|
|
1700
|
+
with self._lock:
|
|
1701
|
+
rows = self._conn.execute(
|
|
1702
|
+
"""SELECT * FROM concern_resolutions
|
|
1703
|
+
ORDER BY resolved_at DESC,resolution_id DESC LIMIT ?""",
|
|
1704
|
+
(bounded,),
|
|
1705
|
+
).fetchall()
|
|
1706
|
+
receipts = []
|
|
1707
|
+
for row in rows:
|
|
1708
|
+
base = self._resolution_receipt(row)
|
|
1709
|
+
intent, terminal = self._cascade_records(base)
|
|
1710
|
+
receipts.append(
|
|
1711
|
+
self._attach_cascade_evidence(base, intent, terminal)
|
|
1712
|
+
)
|
|
1713
|
+
return receipts
|
|
1714
|
+
|
|
1715
|
+
def resolve_with_owner_record(
|
|
1716
|
+
self,
|
|
1717
|
+
concern_id: str,
|
|
1718
|
+
*,
|
|
1719
|
+
outcome: str,
|
|
1720
|
+
note: str,
|
|
1721
|
+
cascade: bool,
|
|
1722
|
+
resolved_by: str,
|
|
1723
|
+
) -> tuple[Dict[str, Any], bool]:
|
|
1724
|
+
"""Resolve once and bind the exact owner decision transactionally.
|
|
1725
|
+
|
|
1726
|
+
The returned boolean is true only for the first write. A byte-exact
|
|
1727
|
+
semantic replay returns the original receipt; a different outcome,
|
|
1728
|
+
note, cascade choice, or resolver conflicts. A legacy receipt also
|
|
1729
|
+
conflicts because its missing facts cannot truthfully be filled in
|
|
1730
|
+
after the event.
|
|
1731
|
+
"""
|
|
1732
|
+
|
|
1733
|
+
if type(cascade) is not bool:
|
|
1734
|
+
raise ValueError("concern resolution cascade must be boolean")
|
|
1735
|
+
expected = {
|
|
1736
|
+
"concern_id": str(concern_id),
|
|
1737
|
+
"outcome": str(outcome),
|
|
1738
|
+
"note": str(note),
|
|
1739
|
+
"cascade": cascade,
|
|
1740
|
+
"resolved_by": str(resolved_by),
|
|
1741
|
+
"provenance": _OWNER_RESOLUTION_PROVENANCE,
|
|
1742
|
+
}
|
|
1743
|
+
with self._lock:
|
|
1744
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
1745
|
+
try:
|
|
1746
|
+
existing = self._conn.execute(
|
|
1747
|
+
"SELECT * FROM concern_resolutions WHERE concern_id=?",
|
|
1748
|
+
(concern_id,),
|
|
1749
|
+
).fetchone()
|
|
1750
|
+
if existing is not None:
|
|
1751
|
+
base = self._resolution_receipt(existing)
|
|
1752
|
+
intent, terminal = self._cascade_records(base)
|
|
1753
|
+
receipt = self._attach_cascade_evidence(base, intent, terminal)
|
|
1754
|
+
if any(base[name] != value for name, value in expected.items()):
|
|
1755
|
+
raise ConcernResolutionConflict(
|
|
1756
|
+
"immutable concern resolution replay mismatch"
|
|
1757
|
+
)
|
|
1758
|
+
self._conn.commit()
|
|
1759
|
+
return receipt, False
|
|
1760
|
+
|
|
1761
|
+
concern = self._conn.execute(
|
|
1762
|
+
"SELECT status,sources FROM concerns WHERE concern_id=?",
|
|
1763
|
+
(concern_id,),
|
|
1764
|
+
).fetchone()
|
|
1765
|
+
if concern is None:
|
|
1766
|
+
raise ValueError("unknown concern")
|
|
1767
|
+
if concern["status"] != "active":
|
|
1768
|
+
raise ConcernResolutionConflict(
|
|
1769
|
+
"concern is terminal without an exact owner resolution receipt"
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
source_refs = self._stored_source_refs(concern["sources"])
|
|
1773
|
+
resolved_at = datetime.now(timezone.utc).isoformat()
|
|
1774
|
+
material = _resolution_material(
|
|
1775
|
+
concern_id=expected["concern_id"],
|
|
1776
|
+
outcome=expected["outcome"],
|
|
1777
|
+
note=expected["note"],
|
|
1778
|
+
cascade=expected["cascade"],
|
|
1779
|
+
resolved_by=expected["resolved_by"],
|
|
1780
|
+
resolved_at=resolved_at,
|
|
1781
|
+
provenance=expected["provenance"],
|
|
1782
|
+
)
|
|
1783
|
+
self._conn.execute(
|
|
1784
|
+
"""INSERT INTO concern_resolutions
|
|
1785
|
+
(resolution_id,concern_id,outcome,note,note_digest,
|
|
1786
|
+
cascade,resolved_by,resolved_at,provenance,record_digest)
|
|
1787
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
1788
|
+
(
|
|
1789
|
+
material["resolution_id"], material["concern_id"],
|
|
1790
|
+
material["outcome"], material["note"],
|
|
1791
|
+
material["note_digest"], int(material["cascade"]),
|
|
1792
|
+
material["resolved_by"], material["resolved_at"],
|
|
1793
|
+
material["provenance"], material["record_digest"],
|
|
1794
|
+
),
|
|
1795
|
+
)
|
|
1796
|
+
intent = _cascade_intent_material(
|
|
1797
|
+
resolution_id=material["resolution_id"],
|
|
1798
|
+
concern_id=material["concern_id"],
|
|
1799
|
+
requested=material["cascade"],
|
|
1800
|
+
source_refs=source_refs,
|
|
1801
|
+
captured_at=resolved_at,
|
|
1802
|
+
capture_provenance="resolution_transaction",
|
|
1803
|
+
)
|
|
1804
|
+
self._insert_cascade_intent(intent)
|
|
1805
|
+
terminal: Optional[Dict[str, Any]] = None
|
|
1806
|
+
terminal_status = (
|
|
1807
|
+
"not_requested" if material["cascade"] is False
|
|
1808
|
+
else "not_applicable" if not source_refs
|
|
1809
|
+
else None
|
|
1810
|
+
)
|
|
1811
|
+
if terminal_status is not None:
|
|
1812
|
+
terminal = _cascade_material(
|
|
1813
|
+
intent_id=intent["intent_id"],
|
|
1814
|
+
intent_digest=intent["record_digest"],
|
|
1815
|
+
resolution_id=material["resolution_id"],
|
|
1816
|
+
concern_id=material["concern_id"],
|
|
1817
|
+
requested=material["cascade"],
|
|
1818
|
+
source_refs=source_refs,
|
|
1819
|
+
source_capture=intent["capture_provenance"],
|
|
1820
|
+
status=terminal_status,
|
|
1821
|
+
settled_sources=[],
|
|
1822
|
+
failed_sources=[],
|
|
1823
|
+
unexpected_result_count=0,
|
|
1824
|
+
failure_codes=[],
|
|
1825
|
+
failure_digest=None,
|
|
1826
|
+
recorded_at=resolved_at,
|
|
1827
|
+
)
|
|
1828
|
+
self._insert_cascade_receipt(terminal)
|
|
1829
|
+
now = time.time()
|
|
1830
|
+
changed = self._conn.execute(
|
|
1831
|
+
"""UPDATE concerns SET thoughts_spent=thoughts_spent+1,
|
|
1832
|
+
last_note=?,last_thought_at=?,last_touched=?,salience=0.0,
|
|
1833
|
+
status='resolved' WHERE concern_id=? AND status='active'""",
|
|
1834
|
+
(expected["note"][:500], now, now, concern_id),
|
|
1835
|
+
).rowcount
|
|
1836
|
+
if changed != 1:
|
|
1837
|
+
raise ConcernResolutionConflict(
|
|
1838
|
+
"concern changed before its owner resolution was recorded"
|
|
1839
|
+
)
|
|
1840
|
+
self._conn.commit()
|
|
1841
|
+
return self._attach_cascade_evidence(
|
|
1842
|
+
material, intent, terminal,
|
|
1843
|
+
), True
|
|
1844
|
+
except Exception:
|
|
1845
|
+
self._conn.rollback()
|
|
1846
|
+
raise
|
|
1847
|
+
|
|
1848
|
+
@staticmethod
|
|
1849
|
+
def _cascade_execution_summary(
|
|
1850
|
+
intent: Mapping[str, Any],
|
|
1851
|
+
results: Any,
|
|
1852
|
+
error: Optional[BaseException],
|
|
1853
|
+
) -> Dict[str, Any]:
|
|
1854
|
+
refs = list(intent["source_refs"])
|
|
1855
|
+
accepted: Dict[str, Mapping[str, Any]] = {}
|
|
1856
|
+
diagnostics: List[Dict[str, Any]] = []
|
|
1857
|
+
codes: set[str] = set()
|
|
1858
|
+
unexpected = 0
|
|
1859
|
+
if not isinstance(results, (list, tuple)):
|
|
1860
|
+
results = []
|
|
1861
|
+
unexpected += 1
|
|
1862
|
+
codes.add("malformed_result")
|
|
1863
|
+
elif len(results) > 60:
|
|
1864
|
+
unexpected += len(results) - 60
|
|
1865
|
+
codes.add("malformed_result")
|
|
1866
|
+
results = results[:60]
|
|
1867
|
+
for item in results:
|
|
1868
|
+
if not isinstance(item, Mapping):
|
|
1869
|
+
unexpected += 1
|
|
1870
|
+
codes.add("malformed_result")
|
|
1871
|
+
continue
|
|
1872
|
+
source = item.get("source")
|
|
1873
|
+
if type(source) is not str:
|
|
1874
|
+
unexpected += 1
|
|
1875
|
+
codes.add("malformed_result")
|
|
1876
|
+
continue
|
|
1877
|
+
try:
|
|
1878
|
+
normalized = _cascade_source_refs([source])[0]
|
|
1879
|
+
except (IndexError, ValueError):
|
|
1880
|
+
unexpected += 1
|
|
1881
|
+
codes.add("malformed_result")
|
|
1882
|
+
continue
|
|
1883
|
+
if normalized not in refs:
|
|
1884
|
+
unexpected += 1
|
|
1885
|
+
codes.add("unknown_result")
|
|
1886
|
+
continue
|
|
1887
|
+
if normalized in accepted:
|
|
1888
|
+
unexpected += 1
|
|
1889
|
+
codes.add("duplicate_result")
|
|
1890
|
+
continue
|
|
1891
|
+
accepted[normalized] = item
|
|
1892
|
+
|
|
1893
|
+
settled: List[str] = []
|
|
1894
|
+
failed: List[str] = []
|
|
1895
|
+
for ref in refs:
|
|
1896
|
+
item = accepted.get(ref)
|
|
1897
|
+
if item is None:
|
|
1898
|
+
failed.append(ref)
|
|
1899
|
+
codes.add("missing_result")
|
|
1900
|
+
diagnostics.append({"source": ref, "code": "missing_result"})
|
|
1901
|
+
continue
|
|
1902
|
+
raw_error = item.get("error")
|
|
1903
|
+
if raw_error:
|
|
1904
|
+
failed.append(ref)
|
|
1905
|
+
error_code = (
|
|
1906
|
+
raw_error
|
|
1907
|
+
if raw_error in {
|
|
1908
|
+
"operation_conflict", "operation_unverified",
|
|
1909
|
+
"settler_error",
|
|
1910
|
+
}
|
|
1911
|
+
else "settler_error"
|
|
1912
|
+
)
|
|
1913
|
+
codes.add(error_code)
|
|
1914
|
+
supplied_digest = item.get("error_digest")
|
|
1915
|
+
error_digest = (
|
|
1916
|
+
supplied_digest
|
|
1917
|
+
if type(supplied_digest) is str
|
|
1918
|
+
and re.fullmatch(r"[a-f0-9]{64}", supplied_digest)
|
|
1919
|
+
else hashlib.sha256(
|
|
1920
|
+
str(raw_error).encode("utf-8", errors="replace")
|
|
1921
|
+
).hexdigest()
|
|
1922
|
+
)
|
|
1923
|
+
diagnostics.append({
|
|
1924
|
+
"source": ref,
|
|
1925
|
+
"code": error_code,
|
|
1926
|
+
"error_digest": error_digest,
|
|
1927
|
+
})
|
|
1928
|
+
elif item.get("settled") is not True:
|
|
1929
|
+
failed.append(ref)
|
|
1930
|
+
codes.add("not_settled")
|
|
1931
|
+
diagnostics.append({"source": ref, "code": "not_settled"})
|
|
1932
|
+
else:
|
|
1933
|
+
settled.append(ref)
|
|
1934
|
+
diagnostics.append({"source": ref, "code": "settled"})
|
|
1935
|
+
|
|
1936
|
+
if error is not None:
|
|
1937
|
+
codes.add("execution_error")
|
|
1938
|
+
diagnostics.append({
|
|
1939
|
+
"code": "execution_error",
|
|
1940
|
+
"error_digest": hashlib.sha256(
|
|
1941
|
+
str(error).encode("utf-8", errors="replace")
|
|
1942
|
+
).hexdigest(),
|
|
1943
|
+
})
|
|
1944
|
+
failed_status = bool(failed or unexpected or error is not None)
|
|
1945
|
+
failure_digest = None
|
|
1946
|
+
if failed_status:
|
|
1947
|
+
failure_digest = hashlib.sha256(_canonical_json({
|
|
1948
|
+
"intent_id": intent["intent_id"],
|
|
1949
|
+
"failure_codes": sorted(codes),
|
|
1950
|
+
"unexpected_result_count": unexpected,
|
|
1951
|
+
"diagnostics": diagnostics,
|
|
1952
|
+
}).encode("utf-8")).hexdigest()
|
|
1953
|
+
return {
|
|
1954
|
+
"status": "failed" if failed_status else "succeeded",
|
|
1955
|
+
"settled_sources": settled,
|
|
1956
|
+
"failed_sources": failed,
|
|
1957
|
+
"unexpected_result_count": unexpected,
|
|
1958
|
+
"failure_codes": sorted(codes),
|
|
1959
|
+
"failure_digest": failure_digest,
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
def finalize_owner_cascade(
|
|
1963
|
+
self,
|
|
1964
|
+
concern_id: str,
|
|
1965
|
+
*,
|
|
1966
|
+
results: Any,
|
|
1967
|
+
error: Optional[BaseException] = None,
|
|
1968
|
+
) -> Dict[str, Any]:
|
|
1969
|
+
"""Append one terminal outcome for a transaction-bound cascade intent."""
|
|
1970
|
+
|
|
1971
|
+
with self._lock:
|
|
1972
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
1973
|
+
try:
|
|
1974
|
+
row = self._conn.execute(
|
|
1975
|
+
"SELECT * FROM concern_resolutions WHERE concern_id=?",
|
|
1976
|
+
(concern_id,),
|
|
1977
|
+
).fetchone()
|
|
1978
|
+
if row is None:
|
|
1979
|
+
raise ValueError("unknown concern resolution")
|
|
1980
|
+
base = self._resolution_receipt(row)
|
|
1981
|
+
intent, existing = self._cascade_records(base)
|
|
1982
|
+
if not (
|
|
1983
|
+
base["provenance"] == _OWNER_RESOLUTION_PROVENANCE
|
|
1984
|
+
and intent["capture_provenance"] == "resolution_transaction"
|
|
1985
|
+
and intent["requested"] is True
|
|
1986
|
+
and intent["source_refs"]
|
|
1987
|
+
):
|
|
1988
|
+
raise ConcernResolutionConflict(
|
|
1989
|
+
"concern does not have a pending cascade intent"
|
|
1990
|
+
)
|
|
1991
|
+
summary = self._cascade_execution_summary(intent, results, error)
|
|
1992
|
+
recorded_at = (
|
|
1993
|
+
existing["recorded_at"] if existing is not None
|
|
1994
|
+
else datetime.now(timezone.utc).isoformat()
|
|
1995
|
+
)
|
|
1996
|
+
terminal = _cascade_material(
|
|
1997
|
+
intent_id=intent["intent_id"],
|
|
1998
|
+
intent_digest=intent["record_digest"],
|
|
1999
|
+
resolution_id=base["resolution_id"],
|
|
2000
|
+
concern_id=base["concern_id"],
|
|
2001
|
+
requested=intent["requested"],
|
|
2002
|
+
source_refs=intent["source_refs"],
|
|
2003
|
+
source_capture=intent["capture_provenance"],
|
|
2004
|
+
recorded_at=recorded_at,
|
|
2005
|
+
**summary,
|
|
2006
|
+
)
|
|
2007
|
+
if existing is not None:
|
|
2008
|
+
if existing != terminal:
|
|
2009
|
+
raise ConcernResolutionConflict(
|
|
2010
|
+
"immutable cascade outcome replay mismatch"
|
|
2011
|
+
)
|
|
2012
|
+
else:
|
|
2013
|
+
self._insert_cascade_receipt(terminal)
|
|
2014
|
+
self._conn.commit()
|
|
2015
|
+
return self._attach_cascade_evidence(base, intent, terminal)
|
|
2016
|
+
except Exception:
|
|
2017
|
+
self._conn.rollback()
|
|
2018
|
+
raise
|
|
2019
|
+
|
|
2020
|
+
def settle_with_evidence(
|
|
2021
|
+
self,
|
|
2022
|
+
concern_id: str,
|
|
2023
|
+
*,
|
|
2024
|
+
settlement_kind: str,
|
|
2025
|
+
settlement_ref: str,
|
|
2026
|
+
evidence_refs: List[str],
|
|
2027
|
+
reason: str,
|
|
2028
|
+
) -> Dict[str, Any]:
|
|
2029
|
+
"""Atomically settle one concern from server-validated evidence.
|
|
2030
|
+
|
|
2031
|
+
This does not mutate the upstream commitment/service/relationship
|
|
2032
|
+
source. The P3 cognition spine is the intended caller; model output
|
|
2033
|
+
alone has no access to this method.
|
|
2034
|
+
"""
|
|
2035
|
+
|
|
2036
|
+
kind = str(settlement_kind or "").strip()
|
|
2037
|
+
if kind not in {"no_action", "project_outcome"}:
|
|
2038
|
+
raise ValueError("unsupported concern settlement kind")
|
|
2039
|
+
ref = str(settlement_ref or "").strip()[:256]
|
|
2040
|
+
refs = list(dict.fromkeys(
|
|
2041
|
+
str(value or "").strip()[:256]
|
|
2042
|
+
for value in evidence_refs or []
|
|
2043
|
+
if str(value or "").strip()
|
|
2044
|
+
))[:60]
|
|
2045
|
+
if not ref or not refs or not str(reason or "").strip():
|
|
2046
|
+
raise ValueError("settlement reference, evidence, and reason are required")
|
|
2047
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
2048
|
+
with self._lock:
|
|
2049
|
+
existing = self._conn.execute(
|
|
2050
|
+
"SELECT * FROM concern_settlements WHERE concern_id=?",
|
|
2051
|
+
(concern_id,),
|
|
2052
|
+
).fetchone()
|
|
2053
|
+
if existing is not None:
|
|
2054
|
+
row = dict(existing)
|
|
2055
|
+
if (
|
|
2056
|
+
row["settlement_ref"] != ref
|
|
2057
|
+
or row["settlement_kind"] != kind
|
|
2058
|
+
or json.loads(row["evidence_refs"] or "[]") != refs
|
|
2059
|
+
or row["reason"] != str(reason)[:800]
|
|
2060
|
+
):
|
|
2061
|
+
raise ValueError("immutable concern settlement replay mismatch")
|
|
2062
|
+
row["evidence_refs"] = refs
|
|
2063
|
+
return row
|
|
2064
|
+
concern = self._conn.execute(
|
|
2065
|
+
"SELECT status FROM concerns WHERE concern_id=?",
|
|
2066
|
+
(concern_id,),
|
|
2067
|
+
).fetchone()
|
|
2068
|
+
if concern is None:
|
|
2069
|
+
raise ValueError("unknown concern")
|
|
2070
|
+
if concern["status"] != "active":
|
|
2071
|
+
raise ValueError("concern is already terminal without this settlement")
|
|
2072
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
2073
|
+
try:
|
|
2074
|
+
self._conn.execute(
|
|
2075
|
+
"""INSERT INTO concern_settlements
|
|
2076
|
+
(settlement_ref,concern_id,settlement_kind,evidence_refs,
|
|
2077
|
+
reason,settled_at) VALUES (?,?,?,?,?,?)""",
|
|
2078
|
+
(ref, concern_id, kind, json.dumps(refs),
|
|
2079
|
+
str(reason)[:800], now_iso),
|
|
2080
|
+
)
|
|
2081
|
+
self._conn.execute(
|
|
2082
|
+
"""UPDATE concerns SET status='resolved',salience=0.0,
|
|
2083
|
+
last_note=?,last_touched=? WHERE concern_id=?""",
|
|
2084
|
+
(str(reason)[:500], time.time(), concern_id),
|
|
2085
|
+
)
|
|
2086
|
+
self._conn.commit()
|
|
2087
|
+
except Exception:
|
|
2088
|
+
self._conn.rollback()
|
|
2089
|
+
raise
|
|
2090
|
+
return self.get_settlement(concern_id) or {}
|
|
2091
|
+
|
|
2092
|
+
def get_settlement(self, concern_id: str) -> Optional[Dict[str, Any]]:
|
|
2093
|
+
with self._lock:
|
|
2094
|
+
row = self._conn.execute(
|
|
2095
|
+
"SELECT * FROM concern_settlements WHERE concern_id=?",
|
|
2096
|
+
(concern_id,),
|
|
2097
|
+
).fetchone()
|
|
2098
|
+
if row is None:
|
|
2099
|
+
return None
|
|
2100
|
+
result = dict(row)
|
|
2101
|
+
result["evidence_refs"] = json.loads(result["evidence_refs"] or "[]")
|
|
2102
|
+
return result
|
|
2103
|
+
|
|
2104
|
+
def set_memory_refs(self, concern_id: str, ids: List[str], cap: int = 12) -> None:
|
|
2105
|
+
"""Record which memories the thinker consulted about this concern, most
|
|
2106
|
+
recent first, deduped and capped. A real, measured provenance link."""
|
|
2107
|
+
clean = [str(i) for i in (ids or []) if i]
|
|
2108
|
+
if not clean:
|
|
2109
|
+
return
|
|
2110
|
+
with self._lock:
|
|
2111
|
+
r = self._conn.execute(
|
|
2112
|
+
"SELECT memory_refs FROM concerns WHERE concern_id=?",
|
|
2113
|
+
(concern_id,)).fetchone()
|
|
2114
|
+
if r is None:
|
|
2115
|
+
return
|
|
2116
|
+
prior = []
|
|
2117
|
+
try:
|
|
2118
|
+
prior = json.loads((r["memory_refs"] if "memory_refs" in r.keys() else None) or "[]")
|
|
2119
|
+
except Exception:
|
|
2120
|
+
prior = []
|
|
2121
|
+
merged = list(dict.fromkeys(clean + prior))[:cap]
|
|
2122
|
+
self._conn.execute(
|
|
2123
|
+
"UPDATE concerns SET memory_refs=? WHERE concern_id=?",
|
|
2124
|
+
(json.dumps(merged), concern_id))
|
|
2125
|
+
self._conn.commit()
|
|
2126
|
+
|
|
2127
|
+
def resolve_by_dedup(self, dedup_key: str, note: str) -> int:
|
|
2128
|
+
"""Resolve every ACTIVE concern carrying this dedup_key (reverse
|
|
2129
|
+
cascade: when the source itself gets settled directly, the concern
|
|
2130
|
+
raised from it must leave her mind too). Returns count resolved."""
|
|
2131
|
+
now = time.time()
|
|
2132
|
+
with self._lock:
|
|
2133
|
+
rows = self._conn.execute(
|
|
2134
|
+
"SELECT concern_id FROM concerns WHERE dedup_key=? "
|
|
2135
|
+
"AND status='active'", (dedup_key,)).fetchall()
|
|
2136
|
+
for r in rows:
|
|
2137
|
+
self._conn.execute(
|
|
2138
|
+
"UPDATE concerns SET status='resolved', salience=0.0,"
|
|
2139
|
+
" last_note=?, last_touched=? WHERE concern_id=?",
|
|
2140
|
+
(note[:500], now, r["concern_id"]))
|
|
2141
|
+
self._conn.commit()
|
|
2142
|
+
return len(rows)
|
|
2143
|
+
|
|
2144
|
+
def evict_below(
|
|
2145
|
+
self,
|
|
2146
|
+
floor: float,
|
|
2147
|
+
keep: int,
|
|
2148
|
+
*,
|
|
2149
|
+
excluded_producer_modes: Iterable[tuple[str, str]] = (),
|
|
2150
|
+
) -> int:
|
|
2151
|
+
"""Evict active concerns under the floor, and any beyond the capacity
|
|
2152
|
+
cap (lowest salience first). Returns count evicted."""
|
|
2153
|
+
with self._lock:
|
|
2154
|
+
exclusions = self._producer_mode_exclusions(
|
|
2155
|
+
excluded_producer_modes,
|
|
2156
|
+
)
|
|
2157
|
+
where = "status='active'"
|
|
2158
|
+
params: List[Any] = []
|
|
2159
|
+
if exclusions:
|
|
2160
|
+
terms = " OR ".join(
|
|
2161
|
+
"(COALESCE(producer_name,'legacy')=? AND "
|
|
2162
|
+
"COALESCE(producer_mode,'unknown')=?)"
|
|
2163
|
+
for _ in exclusions
|
|
2164
|
+
)
|
|
2165
|
+
where += f" AND NOT ({terms})"
|
|
2166
|
+
for producer, mode in exclusions:
|
|
2167
|
+
params.extend((producer, mode))
|
|
2168
|
+
rows = self._conn.execute(
|
|
2169
|
+
"SELECT concern_id, salience FROM concerns "
|
|
2170
|
+
f"WHERE {where} ORDER BY salience DESC",
|
|
2171
|
+
params,
|
|
2172
|
+
).fetchall()
|
|
2173
|
+
to_evict = [r["concern_id"] for r in rows[keep:]]
|
|
2174
|
+
to_evict += [r["concern_id"] for r in rows[:keep]
|
|
2175
|
+
if r["salience"] < floor]
|
|
2176
|
+
for cid in set(to_evict):
|
|
2177
|
+
self._conn.execute(
|
|
2178
|
+
"UPDATE concerns SET status='evicted' WHERE concern_id=?",
|
|
2179
|
+
(cid,))
|
|
2180
|
+
self._conn.commit()
|
|
2181
|
+
return len(set(to_evict))
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
class WorkspaceEngine:
|
|
2185
|
+
"""Salience dynamics + the thinking scheduler.
|
|
2186
|
+
|
|
2187
|
+
The thinker is an injected async callable `thinker(concern) -> dict` with
|
|
2188
|
+
keys: progress(bool), resolve(bool), note(str), action(optional dict
|
|
2189
|
+
{"kind": "initiative"|"experiment"|"memory"|"none", ...}). Kept out of
|
|
2190
|
+
this module so ColonyAI stays model-agnostic and tests inject a fake.
|
|
2191
|
+
"""
|
|
2192
|
+
|
|
2193
|
+
def __init__(self, store: ConcernStore, *,
|
|
2194
|
+
thinker: Optional[Callable[[Concern], Awaitable[Dict[str, Any]]]] = None,
|
|
2195
|
+
journal: Any = None,
|
|
2196
|
+
on_action: Optional[Callable[[Concern, Dict[str, Any]], Awaitable[None]]] = None,
|
|
2197
|
+
event_reducer: Any = None,
|
|
2198
|
+
external_event_reducer: Any = None,
|
|
2199
|
+
turn_event_reducer: Any = None,
|
|
2200
|
+
cognition_spine: Any = None) -> None:
|
|
2201
|
+
self.store = store
|
|
2202
|
+
self._thinker = thinker
|
|
2203
|
+
self._journal = journal
|
|
2204
|
+
self._on_action = on_action
|
|
2205
|
+
self.event_reducer = event_reducer
|
|
2206
|
+
self.external_event_reducer = external_event_reducer
|
|
2207
|
+
self.turn_event_reducer = turn_event_reducer
|
|
2208
|
+
self.cognition_spine = cognition_spine
|
|
2209
|
+
|
|
2210
|
+
# -- salience ---------------------------------------------------------
|
|
2211
|
+
def bump(self, *, kind: str, summary: str, dedup_key: str,
|
|
2212
|
+
salience: float = 0.5, sources: Optional[List[str]] = None,
|
|
2213
|
+
max_thoughts: Optional[int] = None,
|
|
2214
|
+
producer_name: str = "workspace",
|
|
2215
|
+
producer_mode: Optional[str] = None,
|
|
2216
|
+
producer_revision: str = "workspace-engine-v2") -> Concern:
|
|
2217
|
+
kind = kind if kind in CONCERN_KINDS else "thread"
|
|
2218
|
+
return self.store.upsert(
|
|
2219
|
+
kind=kind, summary=summary[:300], salience=salience,
|
|
2220
|
+
dedup_key=dedup_key[:200], sources=sources or [],
|
|
2221
|
+
max_thoughts=max_thoughts or _thought_budget(),
|
|
2222
|
+
producer_name=producer_name,
|
|
2223
|
+
producer_mode=producer_mode or workspace_mode(),
|
|
2224
|
+
producer_revision=producer_revision)
|
|
2225
|
+
|
|
2226
|
+
def decay(self) -> int:
|
|
2227
|
+
"""Exponential time-decay of every active concern; evict the floor
|
|
2228
|
+
and anything over capacity. Returns the number evicted."""
|
|
2229
|
+
hl = _decay_half_life_hours() * 3600.0
|
|
2230
|
+
now = time.time()
|
|
2231
|
+
shadow_turns = (("turn_concerns", "shadow"),)
|
|
2232
|
+
for c in self.store.active_for_maintenance(
|
|
2233
|
+
excluded_producer_modes=shadow_turns,
|
|
2234
|
+
limit=500,
|
|
2235
|
+
):
|
|
2236
|
+
dt = max(0.0, now - c.last_touched)
|
|
2237
|
+
factor = 0.5 ** (dt / hl) if hl > 0 else 1.0
|
|
2238
|
+
self.store.set_salience(c.concern_id, c.salience * factor)
|
|
2239
|
+
return self.store.evict_below(
|
|
2240
|
+
_evict_floor(), _capacity(),
|
|
2241
|
+
excluded_producer_modes=shadow_turns,
|
|
2242
|
+
)
|
|
2243
|
+
|
|
2244
|
+
def top(self) -> Optional[Concern]:
|
|
2245
|
+
active = self.store.active(limit=1)
|
|
2246
|
+
return active[0] if active else None
|
|
2247
|
+
|
|
2248
|
+
# -- thinking ---------------------------------------------------------
|
|
2249
|
+
async def think_once(self) -> Optional[Dict[str, Any]]:
|
|
2250
|
+
"""Pop the most salient thinkable concern and run one thought.
|
|
2251
|
+
Returns the outcome dict, or None if nothing to think about."""
|
|
2252
|
+
# Once P3 is live, every autonomous thought must be a durable queue
|
|
2253
|
+
# job. Refuse an accidental fallback to the legacy direct LLM path.
|
|
2254
|
+
try:
|
|
2255
|
+
from apsimo.cognition.goal_spine import cognition_spine_exclusive
|
|
2256
|
+
if cognition_spine_exclusive():
|
|
2257
|
+
return None
|
|
2258
|
+
except Exception:
|
|
2259
|
+
pass
|
|
2260
|
+
if self._thinker is None:
|
|
2261
|
+
return None
|
|
2262
|
+
concern = None
|
|
2263
|
+
excluded_producers = ("external_event_concerns", "turn_concerns")
|
|
2264
|
+
excluding = getattr(self.store, "active_without_producers", None)
|
|
2265
|
+
if callable(excluding):
|
|
2266
|
+
candidates = excluding(excluded_producers, limit=20)
|
|
2267
|
+
else:
|
|
2268
|
+
candidates = [
|
|
2269
|
+
item for item in self.store.active(limit=200)
|
|
2270
|
+
if str(getattr(item, "producer_name", "") or "")
|
|
2271
|
+
not in excluded_producers
|
|
2272
|
+
][:20]
|
|
2273
|
+
for c in candidates:
|
|
2274
|
+
# External reports and conversational evidence may only enter
|
|
2275
|
+
# cognition through the durable, read-only ThoughtJob/current-mode
|
|
2276
|
+
# admission path. The legacy direct thinker and its on_action
|
|
2277
|
+
# callback are never an allowed fallback, even when P3 is disabled
|
|
2278
|
+
# or temporarily unattached.
|
|
2279
|
+
if c.thoughts_spent < c.max_thoughts:
|
|
2280
|
+
concern = c
|
|
2281
|
+
break
|
|
2282
|
+
if concern is None:
|
|
2283
|
+
return None
|
|
2284
|
+
try:
|
|
2285
|
+
outcome = await self._thinker(concern) or {}
|
|
2286
|
+
except Exception as exc:
|
|
2287
|
+
logger.warning("workspace thinker failed: %s", exc)
|
|
2288
|
+
return None
|
|
2289
|
+
progressed = bool(outcome.get("progress"))
|
|
2290
|
+
resolved = bool(outcome.get("resolve"))
|
|
2291
|
+
note = str(outcome.get("note", ""))[:500]
|
|
2292
|
+
# progress sustains salience; no progress decays it harder so
|
|
2293
|
+
# rumination on a stuck concern fades instead of looping forever.
|
|
2294
|
+
new_sal = concern.salience * (0.9 if progressed else 0.6)
|
|
2295
|
+
self.store.record_thought(concern.concern_id, note,
|
|
2296
|
+
resolved=resolved, salience=new_sal)
|
|
2297
|
+
# persist the memory ids the thinker actually recalled about this
|
|
2298
|
+
# concern (provenance for the memory-field beams; render draws a beam
|
|
2299
|
+
# only when a ref id is also a point on the sampled field).
|
|
2300
|
+
refs = outcome.get("memory_refs")
|
|
2301
|
+
if refs:
|
|
2302
|
+
try:
|
|
2303
|
+
self.store.set_memory_refs(concern.concern_id, refs)
|
|
2304
|
+
except Exception:
|
|
2305
|
+
logger.debug("set_memory_refs failed", exc_info=True)
|
|
2306
|
+
self._log(f"thought on {concern.kind}: {concern.summary[:60]} "
|
|
2307
|
+
f"-> {'resolved' if resolved else 'progress' if progressed else 'no progress'}",
|
|
2308
|
+
note)
|
|
2309
|
+
action = outcome.get("action")
|
|
2310
|
+
if action and self._on_action is not None and workspace_mode() == "live":
|
|
2311
|
+
try:
|
|
2312
|
+
await self._on_action(concern, action)
|
|
2313
|
+
except Exception:
|
|
2314
|
+
logger.debug("workspace on_action failed", exc_info=True)
|
|
2315
|
+
return {"concern_id": concern.concern_id, "resolved": resolved,
|
|
2316
|
+
"progress": progressed, "note": note, "action": action}
|
|
2317
|
+
|
|
2318
|
+
def _log(self, desc: str, note: str) -> None:
|
|
2319
|
+
if self._journal is None:
|
|
2320
|
+
return
|
|
2321
|
+
try:
|
|
2322
|
+
self._journal.record("workspace", desc, reasoning=note,
|
|
2323
|
+
decision="noted", outcome="thought")
|
|
2324
|
+
except Exception:
|
|
2325
|
+
logger.debug("workspace journal write failed", exc_info=True)
|
|
2326
|
+
|
|
2327
|
+
# -- read side --------------------------------------------------------
|
|
2328
|
+
def snapshot(
|
|
2329
|
+
self,
|
|
2330
|
+
limit: int = 24,
|
|
2331
|
+
*,
|
|
2332
|
+
viewer_person_id: str = "",
|
|
2333
|
+
owner_person_id: str = "",
|
|
2334
|
+
audiences: set[str] | frozenset[str] = frozenset(),
|
|
2335
|
+
unrestricted: bool = True,
|
|
2336
|
+
) -> Dict[str, Any]:
|
|
2337
|
+
active = (
|
|
2338
|
+
self.store.active(limit=limit)
|
|
2339
|
+
if unrestricted
|
|
2340
|
+
else self.store.active_for_viewer(
|
|
2341
|
+
viewer_person_id=viewer_person_id,
|
|
2342
|
+
owner_person_id=owner_person_id,
|
|
2343
|
+
audiences=audiences,
|
|
2344
|
+
limit=limit,
|
|
2345
|
+
)
|
|
2346
|
+
)
|
|
2347
|
+
result = {"mode": workspace_mode(),
|
|
2348
|
+
"capacity": _capacity(),
|
|
2349
|
+
"sleeping": in_sleep_window(),
|
|
2350
|
+
"concerns": [c.public() for c in active]}
|
|
2351
|
+
owner_receipts_visible = unrestricted or bool(
|
|
2352
|
+
viewer_person_id
|
|
2353
|
+
and owner_person_id
|
|
2354
|
+
and viewer_person_id == owner_person_id
|
|
2355
|
+
and "owner" in audiences
|
|
2356
|
+
)
|
|
2357
|
+
if owner_receipts_visible:
|
|
2358
|
+
result["recent_resolutions"] = self.store.recent_resolutions()
|
|
2359
|
+
if self.event_reducer is not None:
|
|
2360
|
+
try:
|
|
2361
|
+
result["event_reducer"] = self.event_reducer.status()
|
|
2362
|
+
except Exception:
|
|
2363
|
+
result["event_reducer"] = {
|
|
2364
|
+
"enabled": True,
|
|
2365
|
+
"healthy": False,
|
|
2366
|
+
"error": "status_unavailable",
|
|
2367
|
+
}
|
|
2368
|
+
if self.external_event_reducer is not None:
|
|
2369
|
+
try:
|
|
2370
|
+
result["external_event_reducer"] = (
|
|
2371
|
+
self.external_event_reducer.status()
|
|
2372
|
+
)
|
|
2373
|
+
except Exception:
|
|
2374
|
+
result["external_event_reducer"] = {
|
|
2375
|
+
"enabled": True,
|
|
2376
|
+
"healthy": False,
|
|
2377
|
+
"error": "status_unavailable",
|
|
2378
|
+
}
|
|
2379
|
+
if self.turn_event_reducer is not None:
|
|
2380
|
+
try:
|
|
2381
|
+
result["turn_event_reducer"] = self.turn_event_reducer.status()
|
|
2382
|
+
except Exception:
|
|
2383
|
+
result["turn_event_reducer"] = {
|
|
2384
|
+
"enabled": True,
|
|
2385
|
+
"healthy": False,
|
|
2386
|
+
"error": "status_unavailable",
|
|
2387
|
+
}
|
|
2388
|
+
return result
|