apsimo 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/agents/store.py
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
"""Agent and Invite stores for multi-agent Colony.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- AgentStore: Registry of connected agents with SQLite persistence
|
|
5
|
+
- InviteStore: Setup code management with rate limiting
|
|
6
|
+
- Audit logging
|
|
7
|
+
- Certificate Revocation List (CRL)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import shutil
|
|
16
|
+
import sqlite3
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from .models import Agent, AgentMetadata, AgentStatus
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Type alias for Colony key manager (avoid circular import)
|
|
29
|
+
LocalKeyManager = Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_state_dir() -> Path:
|
|
33
|
+
"""Get Colony state directory."""
|
|
34
|
+
state_dir = os.environ.get("COLONY_STATE_DIR")
|
|
35
|
+
if state_dir:
|
|
36
|
+
return Path(state_dir)
|
|
37
|
+
return Path.home() / ".colony" / "data"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_setup_code() -> str:
|
|
41
|
+
"""Generate a random setup code: COLONY-XXXX-XXXX-XXXX."""
|
|
42
|
+
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No 0/O, 1/I confusion
|
|
43
|
+
segments = ["COLONY"]
|
|
44
|
+
for _ in range(3):
|
|
45
|
+
segment = "".join(secrets.choice(chars) for _ in range(4))
|
|
46
|
+
segments.append(segment)
|
|
47
|
+
return "-".join(segments)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hash_setup_code(code: str) -> str:
|
|
51
|
+
"""Hash setup code for secure storage."""
|
|
52
|
+
pepper = os.environ.get(
|
|
53
|
+
"COLONY_CODE_PEPPER",
|
|
54
|
+
"default-pepper-change-in-production",
|
|
55
|
+
)
|
|
56
|
+
return hashlib.sha256(f"{code}:{pepper}".encode()).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentStore:
|
|
60
|
+
"""Manages agent registry with SQLite persistence and CRL support."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
state_dir: Optional[Path] = None,
|
|
65
|
+
colony_key_manager: Optional[LocalKeyManager] = None,
|
|
66
|
+
):
|
|
67
|
+
self._state_dir = Path(state_dir) if state_dir else get_state_dir()
|
|
68
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self._db_path = self._state_dir / "agents.db"
|
|
70
|
+
self._backup_path = self._state_dir / "agents.db.backup"
|
|
71
|
+
self._colony_km = colony_key_manager
|
|
72
|
+
|
|
73
|
+
# In-memory CRL for fast lookup
|
|
74
|
+
self._revoked_node_ids: set = set()
|
|
75
|
+
|
|
76
|
+
self._db = self._init_db()
|
|
77
|
+
self._load_crl()
|
|
78
|
+
|
|
79
|
+
def _init_db(self) -> sqlite3.Connection:
|
|
80
|
+
"""Initialize database with recovery."""
|
|
81
|
+
try:
|
|
82
|
+
return self._connect()
|
|
83
|
+
except sqlite3.DatabaseError:
|
|
84
|
+
logger.warning("agents.db corrupted, attempting recovery")
|
|
85
|
+
|
|
86
|
+
if self._backup_path.exists():
|
|
87
|
+
shutil.copy(self._backup_path, self._db_path)
|
|
88
|
+
logger.info("Restored agents.db from backup")
|
|
89
|
+
else:
|
|
90
|
+
self._db_path.unlink(missing_ok=True)
|
|
91
|
+
logger.warning("No backup available, starting fresh")
|
|
92
|
+
|
|
93
|
+
return self._connect()
|
|
94
|
+
|
|
95
|
+
def _connect(self) -> sqlite3.Connection:
|
|
96
|
+
"""Connect to database with WAL mode for reliability."""
|
|
97
|
+
# check_same_thread=False allows TestClient to access the DB from
|
|
98
|
+
# a different thread (test thread vs event loop thread). This is
|
|
99
|
+
# safe for tests; production uses a single process/thread.
|
|
100
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
101
|
+
conn.row_factory = sqlite3.Row
|
|
102
|
+
|
|
103
|
+
# WAL mode for better crash recovery
|
|
104
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
105
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
106
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
107
|
+
|
|
108
|
+
self._create_tables(conn)
|
|
109
|
+
self._create_audit_tables(conn)
|
|
110
|
+
|
|
111
|
+
return conn
|
|
112
|
+
|
|
113
|
+
def _create_tables(self, conn: sqlite3.Connection) -> None:
|
|
114
|
+
"""Create agents table."""
|
|
115
|
+
conn.execute(
|
|
116
|
+
"""
|
|
117
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
118
|
+
agent_id TEXT PRIMARY KEY,
|
|
119
|
+
node_id TEXT NOT NULL,
|
|
120
|
+
colony_id TEXT NOT NULL,
|
|
121
|
+
name TEXT NOT NULL,
|
|
122
|
+
|
|
123
|
+
connection_mode TEXT DEFAULT 'local',
|
|
124
|
+
gateway_url TEXT,
|
|
125
|
+
websocket_connected INTEGER DEFAULT 0,
|
|
126
|
+
|
|
127
|
+
capabilities TEXT DEFAULT '[]',
|
|
128
|
+
is_primary INTEGER DEFAULT 0,
|
|
129
|
+
priority INTEGER DEFAULT 1,
|
|
130
|
+
max_concurrent INTEGER DEFAULT 5,
|
|
131
|
+
max_initiatives_per_hour INTEGER DEFAULT 10,
|
|
132
|
+
excluded_types TEXT DEFAULT '[]',
|
|
133
|
+
included_types TEXT DEFAULT '[]',
|
|
134
|
+
|
|
135
|
+
status TEXT DEFAULT 'offline',
|
|
136
|
+
current_assignments INTEGER DEFAULT 0,
|
|
137
|
+
last_seen_at TIMESTAMP,
|
|
138
|
+
|
|
139
|
+
metadata TEXT DEFAULT '{}',
|
|
140
|
+
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
141
|
+
node_cert TEXT,
|
|
142
|
+
|
|
143
|
+
UNIQUE(node_id, colony_id)
|
|
144
|
+
)
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Indexes
|
|
149
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)")
|
|
150
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_agents_primary ON agents(is_primary)")
|
|
151
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_agents_colony ON agents(colony_id)")
|
|
152
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen_at)")
|
|
153
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_agents_node_id ON agents(node_id)")
|
|
154
|
+
|
|
155
|
+
conn.commit()
|
|
156
|
+
|
|
157
|
+
def _create_audit_tables(self, conn: sqlite3.Connection) -> None:
|
|
158
|
+
"""Create audit log table."""
|
|
159
|
+
conn.execute(
|
|
160
|
+
"""
|
|
161
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
162
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
163
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
164
|
+
action TEXT NOT NULL,
|
|
165
|
+
actor TEXT,
|
|
166
|
+
target TEXT,
|
|
167
|
+
details TEXT,
|
|
168
|
+
ip_address TEXT,
|
|
169
|
+
user_agent TEXT
|
|
170
|
+
)
|
|
171
|
+
"""
|
|
172
|
+
)
|
|
173
|
+
conn.execute(
|
|
174
|
+
"CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC)"
|
|
175
|
+
)
|
|
176
|
+
conn.execute(
|
|
177
|
+
"CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action)"
|
|
178
|
+
)
|
|
179
|
+
conn.execute(
|
|
180
|
+
"CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor)"
|
|
181
|
+
)
|
|
182
|
+
conn.commit()
|
|
183
|
+
|
|
184
|
+
def _load_crl(self) -> None:
|
|
185
|
+
"""Load CRL from database into memory."""
|
|
186
|
+
cursor = self._db.execute(
|
|
187
|
+
"SELECT node_id FROM agents WHERE status = ?",
|
|
188
|
+
[AgentStatus.REVOKED.value],
|
|
189
|
+
)
|
|
190
|
+
self._revoked_node_ids = {row["node_id"] for row in cursor.fetchall()}
|
|
191
|
+
logger.info("Loaded CRL: %d revoked node_ids", len(self._revoked_node_ids))
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# Agent CRUD
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def create(self, data: Dict[str, Any]) -> Agent:
|
|
198
|
+
"""Create a new agent."""
|
|
199
|
+
agent_id = data.get("agent_id") or str(uuid.uuid4())
|
|
200
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
201
|
+
|
|
202
|
+
cursor = self._db.execute(
|
|
203
|
+
"""
|
|
204
|
+
INSERT INTO agents (
|
|
205
|
+
agent_id, node_id, colony_id, name,
|
|
206
|
+
connection_mode, gateway_url,
|
|
207
|
+
capabilities, is_primary, priority, max_concurrent, max_initiatives_per_hour,
|
|
208
|
+
excluded_types, included_types,
|
|
209
|
+
status, current_assignments, last_seen_at,
|
|
210
|
+
metadata, registered_at, node_cert
|
|
211
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
212
|
+
""",
|
|
213
|
+
[
|
|
214
|
+
agent_id,
|
|
215
|
+
data["node_id"],
|
|
216
|
+
data["colony_id"],
|
|
217
|
+
data["name"],
|
|
218
|
+
data.get("connection_mode", "local"),
|
|
219
|
+
data.get("gateway_url"),
|
|
220
|
+
json.dumps(data.get("capabilities", [])),
|
|
221
|
+
1 if data.get("is_primary") else 0,
|
|
222
|
+
data.get("priority", 1),
|
|
223
|
+
data.get("max_concurrent", 5),
|
|
224
|
+
data.get("max_initiatives_per_hour", 10),
|
|
225
|
+
json.dumps(data.get("excluded_types", [])),
|
|
226
|
+
json.dumps(data.get("included_types", [])),
|
|
227
|
+
data.get("status", "offline"),
|
|
228
|
+
data.get("current_assignments", 0),
|
|
229
|
+
data.get("last_seen_at"),
|
|
230
|
+
json.dumps(data.get("metadata", {})),
|
|
231
|
+
now,
|
|
232
|
+
json.dumps(data["node_cert"]) if data.get("node_cert") else None,
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
self._db.commit()
|
|
236
|
+
|
|
237
|
+
# Log audit
|
|
238
|
+
self.log_audit(
|
|
239
|
+
action="agent_create",
|
|
240
|
+
actor="system",
|
|
241
|
+
target=agent_id,
|
|
242
|
+
details={"name": data["name"], "node_id": data["node_id"]},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return self.get(agent_id)
|
|
246
|
+
|
|
247
|
+
def get(self, agent_id: str) -> Optional[Agent]:
|
|
248
|
+
"""Get agent by ID."""
|
|
249
|
+
cursor = self._db.execute(
|
|
250
|
+
"SELECT * FROM agents WHERE agent_id = ?",
|
|
251
|
+
[agent_id],
|
|
252
|
+
)
|
|
253
|
+
row = cursor.fetchone()
|
|
254
|
+
if row:
|
|
255
|
+
return Agent.from_row(dict(row))
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def get_by_node_id(self, node_id: str) -> Optional[Agent]:
|
|
259
|
+
"""Get agent by node ID."""
|
|
260
|
+
cursor = self._db.execute(
|
|
261
|
+
"SELECT * FROM agents WHERE node_id = ?",
|
|
262
|
+
[node_id],
|
|
263
|
+
)
|
|
264
|
+
row = cursor.fetchone()
|
|
265
|
+
if row:
|
|
266
|
+
return Agent.from_row(dict(row))
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def list(
|
|
270
|
+
self,
|
|
271
|
+
status: Optional[List[str]] = None,
|
|
272
|
+
colony_id: Optional[str] = None,
|
|
273
|
+
is_primary: Optional[bool] = None,
|
|
274
|
+
capability: Optional[str] = None,
|
|
275
|
+
limit: int = 100,
|
|
276
|
+
offset: int = 0,
|
|
277
|
+
) -> List[Agent]:
|
|
278
|
+
"""List agents with filters."""
|
|
279
|
+
query = "SELECT * FROM agents WHERE 1=1"
|
|
280
|
+
params: List[Any] = []
|
|
281
|
+
|
|
282
|
+
if status:
|
|
283
|
+
placeholders = ",".join("?" * len(status))
|
|
284
|
+
query += f" AND status IN ({placeholders})"
|
|
285
|
+
params.extend(status)
|
|
286
|
+
|
|
287
|
+
if colony_id:
|
|
288
|
+
query += " AND colony_id = ?"
|
|
289
|
+
params.append(colony_id)
|
|
290
|
+
|
|
291
|
+
if is_primary is not None:
|
|
292
|
+
query += " AND is_primary = ?"
|
|
293
|
+
params.append(1 if is_primary else 0)
|
|
294
|
+
|
|
295
|
+
query += " ORDER BY priority DESC, name ASC LIMIT ? OFFSET ?"
|
|
296
|
+
params.extend([limit, offset])
|
|
297
|
+
|
|
298
|
+
cursor = self._db.execute(query, params)
|
|
299
|
+
agents = [Agent.from_row(dict(row)) for row in cursor.fetchall()]
|
|
300
|
+
|
|
301
|
+
if capability:
|
|
302
|
+
agents = [a for a in agents if capability in a.capabilities]
|
|
303
|
+
|
|
304
|
+
return agents
|
|
305
|
+
|
|
306
|
+
def update(self, agent_id: str, **updates) -> Optional[Agent]:
|
|
307
|
+
"""Update agent fields."""
|
|
308
|
+
if not updates:
|
|
309
|
+
return self.get(agent_id)
|
|
310
|
+
|
|
311
|
+
# Build SET clause
|
|
312
|
+
set_parts = []
|
|
313
|
+
params = []
|
|
314
|
+
|
|
315
|
+
for key, value in updates.items():
|
|
316
|
+
if key in (
|
|
317
|
+
"capabilities",
|
|
318
|
+
"excluded_types",
|
|
319
|
+
"included_types",
|
|
320
|
+
"metadata",
|
|
321
|
+
"node_cert",
|
|
322
|
+
):
|
|
323
|
+
set_parts.append(f"{key} = ?")
|
|
324
|
+
params.append(json.dumps(value) if not isinstance(value, str) else value)
|
|
325
|
+
elif key in ("is_primary", "websocket_connected"):
|
|
326
|
+
set_parts.append(f"{key} = ?")
|
|
327
|
+
params.append(1 if value else 0)
|
|
328
|
+
elif key in ("last_seen_at",) and isinstance(value, datetime):
|
|
329
|
+
set_parts.append(f"{key} = ?")
|
|
330
|
+
params.append(value.isoformat())
|
|
331
|
+
else:
|
|
332
|
+
set_parts.append(f"{key} = ?")
|
|
333
|
+
params.append(value)
|
|
334
|
+
|
|
335
|
+
if not set_parts:
|
|
336
|
+
return self.get(agent_id)
|
|
337
|
+
|
|
338
|
+
params.append(agent_id)
|
|
339
|
+
query = f"UPDATE agents SET {', '.join(set_parts)} WHERE agent_id = ?"
|
|
340
|
+
|
|
341
|
+
self._db.execute(query, params)
|
|
342
|
+
self._db.commit()
|
|
343
|
+
|
|
344
|
+
return self.get(agent_id)
|
|
345
|
+
|
|
346
|
+
def delete(self, agent_id: str) -> bool:
|
|
347
|
+
"""Delete an agent."""
|
|
348
|
+
cursor = self._db.execute(
|
|
349
|
+
"DELETE FROM agents WHERE agent_id = ?",
|
|
350
|
+
[agent_id],
|
|
351
|
+
)
|
|
352
|
+
self._db.commit()
|
|
353
|
+
return cursor.rowcount > 0
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
# Status Management
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def set_online(
|
|
360
|
+
self,
|
|
361
|
+
agent_id: str,
|
|
362
|
+
websocket_connected: bool = False,
|
|
363
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
364
|
+
) -> Optional[Agent]:
|
|
365
|
+
"""Mark agent as online."""
|
|
366
|
+
updates = {
|
|
367
|
+
"status": AgentStatus.ONLINE.value,
|
|
368
|
+
"last_seen_at": datetime.now(timezone.utc),
|
|
369
|
+
"websocket_connected": websocket_connected,
|
|
370
|
+
}
|
|
371
|
+
if metadata:
|
|
372
|
+
updates["metadata"] = metadata
|
|
373
|
+
|
|
374
|
+
return self.update(agent_id, **updates)
|
|
375
|
+
|
|
376
|
+
def set_offline(self, agent_id: str) -> Optional[Agent]:
|
|
377
|
+
"""Mark agent as offline."""
|
|
378
|
+
return self.update(
|
|
379
|
+
agent_id,
|
|
380
|
+
status=AgentStatus.OFFLINE.value,
|
|
381
|
+
websocket_connected=False,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def mark_all_offline(self) -> int:
|
|
385
|
+
"""Mark all agents as offline (called on Colony restart)."""
|
|
386
|
+
cursor = self._db.execute(
|
|
387
|
+
"UPDATE agents SET status = ?, websocket_connected = 0",
|
|
388
|
+
[AgentStatus.OFFLINE.value],
|
|
389
|
+
)
|
|
390
|
+
self._db.commit()
|
|
391
|
+
return cursor.rowcount
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
# Revocation
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def revoke(self, agent_id: str, reason: str = "") -> Optional[Agent]:
|
|
398
|
+
"""Revoke an agent."""
|
|
399
|
+
agent = self.get(agent_id)
|
|
400
|
+
if not agent:
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
# Update status
|
|
404
|
+
self.update(agent_id, status=AgentStatus.REVOKED.value)
|
|
405
|
+
|
|
406
|
+
# Add to CRL
|
|
407
|
+
self._revoked_node_ids.add(agent.node_id)
|
|
408
|
+
|
|
409
|
+
# Log audit
|
|
410
|
+
self.log_audit(
|
|
411
|
+
action="agent_revoke",
|
|
412
|
+
actor="api",
|
|
413
|
+
target=agent_id,
|
|
414
|
+
details={"reason": reason, "node_id": agent.node_id},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return self.get(agent_id)
|
|
418
|
+
|
|
419
|
+
def is_revoked(self, node_id: str) -> bool:
|
|
420
|
+
"""Check if node_id is revoked."""
|
|
421
|
+
return node_id in self._revoked_node_ids
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# Assignment Tracking
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def increment_assignments(self, agent_id: str) -> None:
|
|
428
|
+
"""Increment current_assignments counter."""
|
|
429
|
+
self._db.execute(
|
|
430
|
+
"UPDATE agents SET current_assignments = current_assignments + 1 WHERE agent_id = ?",
|
|
431
|
+
[agent_id],
|
|
432
|
+
)
|
|
433
|
+
self._db.commit()
|
|
434
|
+
|
|
435
|
+
def decrement_assignments(self, agent_id: str) -> None:
|
|
436
|
+
"""Decrement current_assignments counter."""
|
|
437
|
+
self._db.execute(
|
|
438
|
+
"UPDATE agents SET current_assignments = MAX(0, current_assignments - 1) WHERE agent_id = ?",
|
|
439
|
+
[agent_id],
|
|
440
|
+
)
|
|
441
|
+
self._db.commit()
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
# Ghost Cleanup
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def list_ghosts(self, registered_before: datetime) -> List[Agent]:
|
|
448
|
+
"""List agents that registered but never connected.
|
|
449
|
+
|
|
450
|
+
Ghost agents are:
|
|
451
|
+
- status='offline'
|
|
452
|
+
- websocket_connected=0
|
|
453
|
+
- last_seen_at IS NULL (never connected)
|
|
454
|
+
- registered_at < threshold
|
|
455
|
+
"""
|
|
456
|
+
cursor = self._db.execute(
|
|
457
|
+
"""
|
|
458
|
+
SELECT * FROM agents
|
|
459
|
+
WHERE status = ?
|
|
460
|
+
AND websocket_connected = 0
|
|
461
|
+
AND last_seen_at IS NULL
|
|
462
|
+
AND registered_at < ?
|
|
463
|
+
""",
|
|
464
|
+
[AgentStatus.OFFLINE.value, registered_before.isoformat()],
|
|
465
|
+
)
|
|
466
|
+
return [Agent.from_row(dict(row)) for row in cursor.fetchall()]
|
|
467
|
+
|
|
468
|
+
# ------------------------------------------------------------------
|
|
469
|
+
# Audit Logging
|
|
470
|
+
# ------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def log_audit(
|
|
473
|
+
self,
|
|
474
|
+
action: str,
|
|
475
|
+
actor: str,
|
|
476
|
+
target: str,
|
|
477
|
+
details: Optional[Dict[str, Any]] = None,
|
|
478
|
+
ip_address: Optional[str] = None,
|
|
479
|
+
user_agent: Optional[str] = None,
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Log audit event."""
|
|
482
|
+
self._db.execute(
|
|
483
|
+
"""
|
|
484
|
+
INSERT INTO audit_log (action, actor, target, details, ip_address, user_agent)
|
|
485
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
486
|
+
""",
|
|
487
|
+
[
|
|
488
|
+
action,
|
|
489
|
+
actor,
|
|
490
|
+
target,
|
|
491
|
+
json.dumps(details) if details else None,
|
|
492
|
+
ip_address,
|
|
493
|
+
user_agent,
|
|
494
|
+
],
|
|
495
|
+
)
|
|
496
|
+
self._db.commit()
|
|
497
|
+
|
|
498
|
+
def get_audit_logs(
|
|
499
|
+
self,
|
|
500
|
+
action: Optional[str] = None,
|
|
501
|
+
actor: Optional[str] = None,
|
|
502
|
+
since: Optional[datetime] = None,
|
|
503
|
+
limit: int = 100,
|
|
504
|
+
) -> List[Dict[str, Any]]:
|
|
505
|
+
"""Get audit logs with filters."""
|
|
506
|
+
query = "SELECT * FROM audit_log WHERE 1=1"
|
|
507
|
+
params: List[Any] = []
|
|
508
|
+
|
|
509
|
+
if action:
|
|
510
|
+
query += " AND action = ?"
|
|
511
|
+
params.append(action)
|
|
512
|
+
|
|
513
|
+
if actor:
|
|
514
|
+
query += " AND actor = ?"
|
|
515
|
+
params.append(actor)
|
|
516
|
+
|
|
517
|
+
if since:
|
|
518
|
+
query += " AND timestamp >= ?"
|
|
519
|
+
params.append(since.isoformat())
|
|
520
|
+
|
|
521
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
522
|
+
params.append(limit)
|
|
523
|
+
|
|
524
|
+
cursor = self._db.execute(query, params)
|
|
525
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
# Certificate Signing
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
async def sign_node_certificate(
|
|
532
|
+
self,
|
|
533
|
+
node_id: str,
|
|
534
|
+
node_public_key: str,
|
|
535
|
+
expires_days: int = 365,
|
|
536
|
+
) -> Dict[str, Any]:
|
|
537
|
+
"""Sign a node certificate for remote agent."""
|
|
538
|
+
if not self._colony_km:
|
|
539
|
+
raise ValueError("Colony key not available for signing")
|
|
540
|
+
|
|
541
|
+
# Import here to avoid circular dependency
|
|
542
|
+
from apsimo.chain.identity import get_or_create_colony_id
|
|
543
|
+
|
|
544
|
+
colony_id = get_or_create_colony_id(self._state_dir)
|
|
545
|
+
|
|
546
|
+
now = datetime.now(timezone.utc)
|
|
547
|
+
expires_at = now + timedelta(days=expires_days)
|
|
548
|
+
|
|
549
|
+
cert = {
|
|
550
|
+
"colony_id": colony_id,
|
|
551
|
+
"node_id": node_id,
|
|
552
|
+
"node_public_key_ed25519": node_public_key,
|
|
553
|
+
"issued_at": now.isoformat(),
|
|
554
|
+
"expires_at": expires_at.isoformat(),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
# Sign with Colony private key
|
|
558
|
+
# The LocalKeyManager should have a sign() method
|
|
559
|
+
payload = json.dumps(cert, sort_keys=True).encode()
|
|
560
|
+
signature = self._colony_km.sign(payload)
|
|
561
|
+
cert["signature"] = signature.hex()
|
|
562
|
+
|
|
563
|
+
return cert
|
|
564
|
+
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
# Backup/Recovery
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
def backup(self) -> None:
|
|
570
|
+
"""Create backup of database."""
|
|
571
|
+
shutil.copy2(self._db_path, self._backup_path)
|
|
572
|
+
|
|
573
|
+
def close(self) -> None:
|
|
574
|
+
"""Close connection and create backup."""
|
|
575
|
+
self.backup()
|
|
576
|
+
self._db.close()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class InviteStore:
|
|
580
|
+
"""Manages agent invitation/setup codes."""
|
|
581
|
+
|
|
582
|
+
MAX_FAILED_ATTEMPTS = 5
|
|
583
|
+
LOCKOUT_MINUTES = 15
|
|
584
|
+
DEFAULT_EXPIRY_SECONDS = 900 # 15 minutes
|
|
585
|
+
|
|
586
|
+
def __init__(self, state_dir: Optional[Path] = None):
|
|
587
|
+
self._state_dir = Path(state_dir) if state_dir else get_state_dir()
|
|
588
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
589
|
+
self._db_path = self._state_dir / "agents.db"
|
|
590
|
+
self._db = self._get_or_create_db()
|
|
591
|
+
|
|
592
|
+
def _get_or_create_db(self) -> sqlite3.Connection:
|
|
593
|
+
"""Get or create database connection."""
|
|
594
|
+
# check_same_thread=False allows TestClient to access the DB from
|
|
595
|
+
# a different thread (test thread vs event loop thread).
|
|
596
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
597
|
+
conn.row_factory = sqlite3.Row
|
|
598
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
599
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
600
|
+
self._create_tables(conn)
|
|
601
|
+
return conn
|
|
602
|
+
|
|
603
|
+
def _create_tables(self, conn: sqlite3.Connection) -> None:
|
|
604
|
+
"""Create invites table."""
|
|
605
|
+
conn.execute(
|
|
606
|
+
"""
|
|
607
|
+
CREATE TABLE IF NOT EXISTS agent_invites (
|
|
608
|
+
code TEXT,
|
|
609
|
+
code_hash TEXT UNIQUE,
|
|
610
|
+
colony_id TEXT NOT NULL,
|
|
611
|
+
|
|
612
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
613
|
+
expires_at TIMESTAMP NOT NULL,
|
|
614
|
+
max_uses INTEGER DEFAULT 1,
|
|
615
|
+
use_count INTEGER DEFAULT 0,
|
|
616
|
+
|
|
617
|
+
failed_attempts INTEGER DEFAULT 0,
|
|
618
|
+
locked_until TIMESTAMP,
|
|
619
|
+
|
|
620
|
+
used_at TIMESTAMP,
|
|
621
|
+
used_by_agent_id TEXT,
|
|
622
|
+
used_by_node_id TEXT,
|
|
623
|
+
|
|
624
|
+
granted_capabilities TEXT DEFAULT '[]',
|
|
625
|
+
granted_is_primary INTEGER DEFAULT 0,
|
|
626
|
+
granted_max_concurrent INTEGER DEFAULT 5,
|
|
627
|
+
|
|
628
|
+
created_by_agent_id TEXT,
|
|
629
|
+
label TEXT
|
|
630
|
+
)
|
|
631
|
+
"""
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
conn.execute(
|
|
635
|
+
"CREATE INDEX IF NOT EXISTS idx_invites_expires ON agent_invites(expires_at)"
|
|
636
|
+
)
|
|
637
|
+
conn.execute(
|
|
638
|
+
"CREATE INDEX IF NOT EXISTS idx_invites_code_hash ON agent_invites(code_hash)"
|
|
639
|
+
)
|
|
640
|
+
conn.execute(
|
|
641
|
+
"CREATE INDEX IF NOT EXISTS idx_invites_locked ON agent_invites(locked_until)"
|
|
642
|
+
)
|
|
643
|
+
conn.commit()
|
|
644
|
+
|
|
645
|
+
def create(
|
|
646
|
+
self,
|
|
647
|
+
colony_id: str,
|
|
648
|
+
capabilities: Optional[List[str]] = None,
|
|
649
|
+
is_primary: bool = False,
|
|
650
|
+
max_concurrent: int = 5,
|
|
651
|
+
expires_seconds: Optional[int] = None,
|
|
652
|
+
label: Optional[str] = None,
|
|
653
|
+
created_by_agent_id: Optional[str] = None,
|
|
654
|
+
) -> Dict[str, Any]:
|
|
655
|
+
"""Create a new invite."""
|
|
656
|
+
code = generate_setup_code()
|
|
657
|
+
code_hash = hash_setup_code(code)
|
|
658
|
+
|
|
659
|
+
now = datetime.now(timezone.utc)
|
|
660
|
+
expires_at = now + timedelta(seconds=expires_seconds or self.DEFAULT_EXPIRY_SECONDS)
|
|
661
|
+
|
|
662
|
+
self._db.execute(
|
|
663
|
+
"""
|
|
664
|
+
INSERT INTO agent_invites (
|
|
665
|
+
code, code_hash, colony_id,
|
|
666
|
+
expires_at,
|
|
667
|
+
granted_capabilities, granted_is_primary, granted_max_concurrent,
|
|
668
|
+
label, created_by_agent_id
|
|
669
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
670
|
+
""",
|
|
671
|
+
[
|
|
672
|
+
code, # Keep for display, but lookups use hash
|
|
673
|
+
code_hash,
|
|
674
|
+
colony_id,
|
|
675
|
+
expires_at.isoformat(),
|
|
676
|
+
json.dumps(capabilities or []),
|
|
677
|
+
1 if is_primary else 0,
|
|
678
|
+
max_concurrent,
|
|
679
|
+
label,
|
|
680
|
+
created_by_agent_id,
|
|
681
|
+
],
|
|
682
|
+
)
|
|
683
|
+
self._db.commit()
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
"setup_code": code, # Return plaintext once
|
|
687
|
+
"code_hash": code_hash,
|
|
688
|
+
"expires_at": expires_at.isoformat(),
|
|
689
|
+
"expires_in_seconds": expires_seconds or self.DEFAULT_EXPIRY_SECONDS,
|
|
690
|
+
"capabilities": capabilities or [],
|
|
691
|
+
"is_primary": is_primary,
|
|
692
|
+
"max_concurrent": max_concurrent,
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
def validate(self, code: str) -> Dict[str, Any]:
|
|
696
|
+
"""Validate setup code (checking rate limits).
|
|
697
|
+
|
|
698
|
+
Returns invite data if valid.
|
|
699
|
+
Raises ValueError if invalid, expired, used, or locked.
|
|
700
|
+
"""
|
|
701
|
+
code_hash = hash_setup_code(code)
|
|
702
|
+
now = datetime.now(timezone.utc)
|
|
703
|
+
|
|
704
|
+
cursor = self._db.execute(
|
|
705
|
+
"SELECT * FROM agent_invites WHERE code_hash = ?",
|
|
706
|
+
[code_hash],
|
|
707
|
+
)
|
|
708
|
+
invite = cursor.fetchone()
|
|
709
|
+
|
|
710
|
+
if not invite:
|
|
711
|
+
raise ValueError("Invalid setup code")
|
|
712
|
+
|
|
713
|
+
invite = dict(invite)
|
|
714
|
+
|
|
715
|
+
# Check if locked
|
|
716
|
+
if invite.get("locked_until"):
|
|
717
|
+
locked_until = datetime.fromisoformat(invite["locked_until"])
|
|
718
|
+
if now < locked_until:
|
|
719
|
+
raise ValueError(f"Setup code locked until {locked_until.isoformat()}")
|
|
720
|
+
|
|
721
|
+
# Check expiry
|
|
722
|
+
expires_at = datetime.fromisoformat(invite["expires_at"])
|
|
723
|
+
if now > expires_at:
|
|
724
|
+
raise ValueError("Setup code expired")
|
|
725
|
+
|
|
726
|
+
# Check usage
|
|
727
|
+
if invite["use_count"] >= invite["max_uses"]:
|
|
728
|
+
raise ValueError("Setup code already used")
|
|
729
|
+
|
|
730
|
+
return invite
|
|
731
|
+
|
|
732
|
+
def record_failed_attempt(self, code: str) -> None:
|
|
733
|
+
"""Record failed validation attempt and check lockout."""
|
|
734
|
+
code_hash = hash_setup_code(code)
|
|
735
|
+
now = datetime.now(timezone.utc)
|
|
736
|
+
|
|
737
|
+
# Increment failed attempts
|
|
738
|
+
cursor = self._db.execute(
|
|
739
|
+
"UPDATE agent_invites SET failed_attempts = failed_attempts + 1 WHERE code_hash = ?",
|
|
740
|
+
[code_hash],
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
if cursor.rowcount == 0:
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
# Check if we should lock
|
|
747
|
+
cursor = self._db.execute(
|
|
748
|
+
"SELECT failed_attempts FROM agent_invites WHERE code_hash = ?",
|
|
749
|
+
[code_hash],
|
|
750
|
+
)
|
|
751
|
+
row = cursor.fetchone()
|
|
752
|
+
if row and row["failed_attempts"] >= self.MAX_FAILED_ATTEMPTS:
|
|
753
|
+
locked_until = now + timedelta(minutes=self.LOCKOUT_MINUTES)
|
|
754
|
+
self._db.execute(
|
|
755
|
+
"UPDATE agent_invites SET locked_until = ? WHERE code_hash = ?",
|
|
756
|
+
[locked_until.isoformat(), code_hash],
|
|
757
|
+
)
|
|
758
|
+
logger.warning("Setup code locked due to %d failed attempts", row["failed_attempts"])
|
|
759
|
+
|
|
760
|
+
self._db.commit()
|
|
761
|
+
|
|
762
|
+
def clear_failed_attempts(self, code: str) -> None:
|
|
763
|
+
"""Clear failed attempts after successful use."""
|
|
764
|
+
code_hash = hash_setup_code(code)
|
|
765
|
+
self._db.execute(
|
|
766
|
+
"UPDATE agent_invites SET failed_attempts = 0 WHERE code_hash = ?",
|
|
767
|
+
[code_hash],
|
|
768
|
+
)
|
|
769
|
+
self._db.commit()
|
|
770
|
+
|
|
771
|
+
def use(
|
|
772
|
+
self,
|
|
773
|
+
code: str,
|
|
774
|
+
node_id: str,
|
|
775
|
+
agent_id: str,
|
|
776
|
+
) -> Dict[str, Any]:
|
|
777
|
+
"""Use setup code (atomic operation).
|
|
778
|
+
|
|
779
|
+
This validates, marks as used, and returns the invite data.
|
|
780
|
+
Raises ValueError if already used or invalid.
|
|
781
|
+
"""
|
|
782
|
+
code_hash = hash_setup_code(code)
|
|
783
|
+
now = datetime.now(timezone.utc)
|
|
784
|
+
|
|
785
|
+
# Atomic UPDATE with WHERE conditions
|
|
786
|
+
cursor = self._db.execute(
|
|
787
|
+
"""
|
|
788
|
+
UPDATE agent_invites
|
|
789
|
+
SET used_at = ?,
|
|
790
|
+
used_by_node_id = ?,
|
|
791
|
+
used_by_agent_id = ?,
|
|
792
|
+
use_count = use_count + 1
|
|
793
|
+
WHERE code_hash = ?
|
|
794
|
+
AND used_at IS NULL
|
|
795
|
+
AND expires_at > ?
|
|
796
|
+
AND (locked_until IS NULL OR locked_until < ?)
|
|
797
|
+
""",
|
|
798
|
+
[
|
|
799
|
+
now.isoformat(),
|
|
800
|
+
node_id,
|
|
801
|
+
agent_id,
|
|
802
|
+
code_hash,
|
|
803
|
+
now.isoformat(),
|
|
804
|
+
now.isoformat(),
|
|
805
|
+
],
|
|
806
|
+
)
|
|
807
|
+
self._db.commit()
|
|
808
|
+
|
|
809
|
+
if cursor.rowcount == 0:
|
|
810
|
+
# Either already used, expired, or locked
|
|
811
|
+
raise ValueError("Setup code already used, expired, or locked")
|
|
812
|
+
|
|
813
|
+
# Get the invite
|
|
814
|
+
cursor = self._db.execute(
|
|
815
|
+
"SELECT * FROM agent_invites WHERE code_hash = ?",
|
|
816
|
+
[code_hash],
|
|
817
|
+
)
|
|
818
|
+
return dict(cursor.fetchone())
|
|
819
|
+
|
|
820
|
+
def get(self, code: str) -> Optional[Dict[str, Any]]:
|
|
821
|
+
"""Get invite by code."""
|
|
822
|
+
code_hash = hash_setup_code(code)
|
|
823
|
+
cursor = self._db.execute(
|
|
824
|
+
"SELECT * FROM agent_invites WHERE code_hash = ?",
|
|
825
|
+
[code_hash],
|
|
826
|
+
)
|
|
827
|
+
row = cursor.fetchone()
|
|
828
|
+
return dict(row) if row else None
|
|
829
|
+
|
|
830
|
+
def list(
|
|
831
|
+
self,
|
|
832
|
+
colony_id: Optional[str] = None,
|
|
833
|
+
unused_only: bool = False,
|
|
834
|
+
limit: int = 50,
|
|
835
|
+
) -> List[Dict[str, Any]]:
|
|
836
|
+
"""List invites."""
|
|
837
|
+
query = "SELECT * FROM agent_invites WHERE 1=1"
|
|
838
|
+
params: List[Any] = []
|
|
839
|
+
|
|
840
|
+
if colony_id:
|
|
841
|
+
query += " AND colony_id = ?"
|
|
842
|
+
params.append(colony_id)
|
|
843
|
+
|
|
844
|
+
if unused_only:
|
|
845
|
+
query += " AND used_at IS NULL"
|
|
846
|
+
|
|
847
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
848
|
+
params.append(limit)
|
|
849
|
+
|
|
850
|
+
cursor = self._db.execute(query, params)
|
|
851
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
852
|
+
|
|
853
|
+
def delete(self, code: str) -> bool:
|
|
854
|
+
"""Delete an invite."""
|
|
855
|
+
code_hash = hash_setup_code(code)
|
|
856
|
+
cursor = self._db.execute(
|
|
857
|
+
"DELETE FROM agent_invites WHERE code_hash = ?",
|
|
858
|
+
[code_hash],
|
|
859
|
+
)
|
|
860
|
+
self._db.commit()
|
|
861
|
+
return cursor.rowcount > 0
|