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/backup.py
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"""Full-state backup and restore for Colony.
|
|
2
|
+
|
|
3
|
+
Creates a compressed, optionally encrypted archive of all Colony state:
|
|
4
|
+
- All SQLite databases in COLONY_STATE_DIR (auto-discovered)
|
|
5
|
+
- Original source images referenced by the canonical ledger snapshot
|
|
6
|
+
- Identity files (colony-id, keys, genesis)
|
|
7
|
+
- Config files (scrubbed of secrets)
|
|
8
|
+
- LanceDB vector store
|
|
9
|
+
- Neo4j graph export (Cypher, best-effort)
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
# Backup
|
|
14
|
+
colony backup --full --output ~/backups/
|
|
15
|
+
|
|
16
|
+
# Restore
|
|
17
|
+
colony restore --full colony-backup-20260627T120000.tar.gz
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from contextlib import closing
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import sqlite3
|
|
31
|
+
import stat
|
|
32
|
+
import tarfile
|
|
33
|
+
import tempfile
|
|
34
|
+
import time
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from io import BytesIO
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Optional
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
BACKUP_VERSION = 2
|
|
43
|
+
_GOVERNED_ACTION_LEDGER = Path("governed-actions/ledger.db")
|
|
44
|
+
|
|
45
|
+
_SECRET_KEY_PATTERN = re.compile(
|
|
46
|
+
r"(_KEY|_SECRET|_PASSWORD|_TOKEN|_PASSPHRASE)$", re.IGNORECASE
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _attest_private_governed_ledger(
|
|
51
|
+
path: Path, *, normalize_archive_modes: bool = False
|
|
52
|
+
) -> os.stat_result:
|
|
53
|
+
"""Require the same stable owner-private posture as the live ledger."""
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
parent = path.parent.lstat()
|
|
57
|
+
leaf = path.lstat()
|
|
58
|
+
euid = os.geteuid()
|
|
59
|
+
except (AttributeError, OSError):
|
|
60
|
+
raise ValueError("private governed action ledger posture is invalid") from None
|
|
61
|
+
if (
|
|
62
|
+
not stat.S_ISDIR(parent.st_mode)
|
|
63
|
+
or parent.st_uid != euid
|
|
64
|
+
or not stat.S_ISREG(leaf.st_mode)
|
|
65
|
+
or leaf.st_uid != euid
|
|
66
|
+
or leaf.st_nlink != 1
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("private governed action ledger posture is invalid")
|
|
69
|
+
if normalize_archive_modes:
|
|
70
|
+
try:
|
|
71
|
+
os.chmod(path.parent, 0o700, follow_symlinks=False)
|
|
72
|
+
os.chmod(path, 0o600, follow_symlinks=False)
|
|
73
|
+
parent = path.parent.lstat()
|
|
74
|
+
leaf = path.lstat()
|
|
75
|
+
except OSError:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"private governed action ledger posture is invalid"
|
|
78
|
+
) from None
|
|
79
|
+
if (
|
|
80
|
+
stat.S_IMODE(parent.st_mode) != 0o700
|
|
81
|
+
or stat.S_IMODE(leaf.st_mode) != 0o600
|
|
82
|
+
):
|
|
83
|
+
raise ValueError("private governed action ledger posture is invalid")
|
|
84
|
+
return leaf
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _restore_private_governed_ledger(source: Path, state_dir: Path) -> None:
|
|
88
|
+
"""Restore the evidence ledger atomically without following aliases."""
|
|
89
|
+
|
|
90
|
+
source_stat = _attest_private_governed_ledger(
|
|
91
|
+
source, normalize_archive_modes=True
|
|
92
|
+
)
|
|
93
|
+
_preflight_private_governed_destination(state_dir)
|
|
94
|
+
destination = state_dir / _GOVERNED_ACTION_LEDGER
|
|
95
|
+
parent = destination.parent
|
|
96
|
+
try:
|
|
97
|
+
parent_stat = parent.lstat()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
try:
|
|
100
|
+
parent.mkdir(mode=0o700, parents=True)
|
|
101
|
+
parent_stat = parent.lstat()
|
|
102
|
+
except OSError:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"private governed action ledger destination is invalid"
|
|
105
|
+
) from None
|
|
106
|
+
except OSError:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"private governed action ledger destination is invalid"
|
|
109
|
+
) from None
|
|
110
|
+
|
|
111
|
+
euid = os.geteuid()
|
|
112
|
+
if not stat.S_ISDIR(parent_stat.st_mode) or parent_stat.st_uid != euid:
|
|
113
|
+
raise ValueError("private governed action ledger destination is invalid")
|
|
114
|
+
try:
|
|
115
|
+
os.chmod(parent, 0o700, follow_symlinks=False)
|
|
116
|
+
parent_stat = parent.lstat()
|
|
117
|
+
except OSError:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"private governed action ledger destination is invalid"
|
|
120
|
+
) from None
|
|
121
|
+
if stat.S_IMODE(parent_stat.st_mode) != 0o700:
|
|
122
|
+
raise ValueError("private governed action ledger destination is invalid")
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
destination.lstat()
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
pass
|
|
128
|
+
except OSError:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"private governed action ledger destination is invalid"
|
|
131
|
+
) from None
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("private governed action ledger already exists")
|
|
134
|
+
|
|
135
|
+
source_fd: int | None = None
|
|
136
|
+
destination_fd: int | None = None
|
|
137
|
+
temporary_name: str | None = None
|
|
138
|
+
try:
|
|
139
|
+
source_fd = os.open(
|
|
140
|
+
source,
|
|
141
|
+
os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
142
|
+
)
|
|
143
|
+
opened_source = os.fstat(source_fd)
|
|
144
|
+
if (
|
|
145
|
+
(opened_source.st_dev, opened_source.st_ino)
|
|
146
|
+
!= (source_stat.st_dev, source_stat.st_ino)
|
|
147
|
+
):
|
|
148
|
+
raise ValueError("private governed action ledger source changed")
|
|
149
|
+
destination_fd, temporary_name = tempfile.mkstemp(
|
|
150
|
+
prefix=".ledger-restore-", dir=str(parent)
|
|
151
|
+
)
|
|
152
|
+
os.fchmod(destination_fd, 0o600)
|
|
153
|
+
with os.fdopen(source_fd, "rb") as source_file:
|
|
154
|
+
source_fd = None
|
|
155
|
+
with os.fdopen(destination_fd, "wb") as destination_file:
|
|
156
|
+
destination_fd = None
|
|
157
|
+
shutil.copyfileobj(source_file, destination_file)
|
|
158
|
+
destination_file.flush()
|
|
159
|
+
os.fsync(destination_file.fileno())
|
|
160
|
+
try:
|
|
161
|
+
os.link(temporary_name, destination, follow_symlinks=False)
|
|
162
|
+
except FileExistsError:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"private governed action ledger already exists"
|
|
165
|
+
) from None
|
|
166
|
+
os.unlink(temporary_name)
|
|
167
|
+
temporary_name = None
|
|
168
|
+
os.chmod(destination, 0o600, follow_symlinks=False)
|
|
169
|
+
_attest_private_governed_ledger(destination)
|
|
170
|
+
directory_fd = os.open(
|
|
171
|
+
parent,
|
|
172
|
+
os.O_RDONLY
|
|
173
|
+
| os.O_DIRECTORY
|
|
174
|
+
| os.O_NOFOLLOW
|
|
175
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
os.fsync(directory_fd)
|
|
179
|
+
finally:
|
|
180
|
+
os.close(directory_fd)
|
|
181
|
+
except ValueError:
|
|
182
|
+
raise
|
|
183
|
+
except OSError:
|
|
184
|
+
raise ValueError("private governed action ledger restore failed") from None
|
|
185
|
+
finally:
|
|
186
|
+
if source_fd is not None:
|
|
187
|
+
os.close(source_fd)
|
|
188
|
+
if destination_fd is not None:
|
|
189
|
+
os.close(destination_fd)
|
|
190
|
+
if temporary_name is not None:
|
|
191
|
+
try:
|
|
192
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _preflight_private_governed_destination(state_dir: Path) -> None:
|
|
198
|
+
"""Refuse an evidence rewind before any full-restore state is written."""
|
|
199
|
+
|
|
200
|
+
destination = state_dir / _GOVERNED_ACTION_LEDGER
|
|
201
|
+
try:
|
|
202
|
+
parent = destination.parent.lstat()
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
return
|
|
205
|
+
except OSError:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"private governed action ledger destination is invalid"
|
|
208
|
+
) from None
|
|
209
|
+
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid():
|
|
210
|
+
raise ValueError("private governed action ledger destination is invalid")
|
|
211
|
+
try:
|
|
212
|
+
destination.lstat()
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
return
|
|
215
|
+
except OSError:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"private governed action ledger destination is invalid"
|
|
218
|
+
) from None
|
|
219
|
+
raise ValueError("private governed action ledger already exists")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── Backup ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def create_full_backup(
|
|
226
|
+
state_dir: str | Path,
|
|
227
|
+
output_dir: str | Path,
|
|
228
|
+
*,
|
|
229
|
+
passphrase: Optional[bytes] = None,
|
|
230
|
+
include_graph: bool = True,
|
|
231
|
+
include_vectors: bool = True,
|
|
232
|
+
include_host_paths: Optional[list[str]] = None,
|
|
233
|
+
) -> Path:
|
|
234
|
+
"""Create a full backup archive of Colony state.
|
|
235
|
+
|
|
236
|
+
Returns the path to the created archive.
|
|
237
|
+
"""
|
|
238
|
+
state_dir = Path(state_dir)
|
|
239
|
+
output_dir = Path(output_dir)
|
|
240
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
|
|
242
|
+
colony_id = _read_colony_id(state_dir)
|
|
243
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
244
|
+
base_name = f"colony-backup-{timestamp}"
|
|
245
|
+
|
|
246
|
+
with tempfile.TemporaryDirectory(prefix="colony-backup-") as tmp:
|
|
247
|
+
staging = Path(tmp) / base_name
|
|
248
|
+
staging.mkdir()
|
|
249
|
+
|
|
250
|
+
db_manifest = _snapshot_databases(state_dir, staging / "databases")
|
|
251
|
+
source_images = _snapshot_source_images(state_dir, staging)
|
|
252
|
+
_snapshot_identity(state_dir, staging / "identity")
|
|
253
|
+
_snapshot_config(state_dir, staging / "config")
|
|
254
|
+
|
|
255
|
+
if include_vectors:
|
|
256
|
+
_snapshot_vectors(state_dir, staging / "vector")
|
|
257
|
+
|
|
258
|
+
graph_status = "skipped"
|
|
259
|
+
if include_graph:
|
|
260
|
+
graph_status = _snapshot_graph(state_dir, staging / "graph")
|
|
261
|
+
|
|
262
|
+
if include_host_paths:
|
|
263
|
+
_snapshot_host_state(include_host_paths, staging / "host")
|
|
264
|
+
|
|
265
|
+
meta = {
|
|
266
|
+
"backup_version": BACKUP_VERSION,
|
|
267
|
+
"colony_version": _get_colony_version(),
|
|
268
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
269
|
+
"colony_id": colony_id,
|
|
270
|
+
"colony_id_hmac": _compute_identity_hmac(colony_id),
|
|
271
|
+
"database_manifest": db_manifest,
|
|
272
|
+
"source_images": source_images,
|
|
273
|
+
"graph_status": graph_status,
|
|
274
|
+
"encrypted": passphrase is not None,
|
|
275
|
+
}
|
|
276
|
+
(staging / "meta.json").write_text(
|
|
277
|
+
json.dumps(meta, indent=2) + "\n"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
archive_path = output_dir / f"{base_name}.tar.gz"
|
|
281
|
+
_create_archive(staging, archive_path)
|
|
282
|
+
|
|
283
|
+
if passphrase is not None:
|
|
284
|
+
encrypted_path = output_dir / f"{base_name}.tar.gz.enc"
|
|
285
|
+
_encrypt_file(archive_path, encrypted_path, passphrase)
|
|
286
|
+
archive_path.unlink()
|
|
287
|
+
archive_path = encrypted_path
|
|
288
|
+
|
|
289
|
+
logger.info("Full backup created: %s", archive_path)
|
|
290
|
+
return archive_path
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── Restore ──────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def restore_full_backup(
|
|
297
|
+
archive_path: str | Path,
|
|
298
|
+
state_dir: str | Path,
|
|
299
|
+
*,
|
|
300
|
+
passphrase: Optional[bytes] = None,
|
|
301
|
+
force_identity: bool = False,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Restore Colony state from a full backup archive.
|
|
304
|
+
|
|
305
|
+
Returns a summary dict of what was restored.
|
|
306
|
+
"""
|
|
307
|
+
archive_path = Path(archive_path)
|
|
308
|
+
state_dir = Path(state_dir)
|
|
309
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
|
|
311
|
+
with tempfile.TemporaryDirectory(prefix="colony-restore-") as tmp:
|
|
312
|
+
staging = Path(tmp) / "restore"
|
|
313
|
+
staging.mkdir()
|
|
314
|
+
|
|
315
|
+
actual_archive = archive_path
|
|
316
|
+
if archive_path.suffix == ".enc":
|
|
317
|
+
if passphrase is None:
|
|
318
|
+
raise ValueError("Archive is encrypted; passphrase required")
|
|
319
|
+
actual_archive = Path(tmp) / "decrypted.tar.gz"
|
|
320
|
+
_decrypt_file(archive_path, actual_archive, passphrase)
|
|
321
|
+
|
|
322
|
+
_extract_archive(actual_archive, staging)
|
|
323
|
+
|
|
324
|
+
root = _find_backup_root(staging)
|
|
325
|
+
meta = json.loads((root / "meta.json").read_text())
|
|
326
|
+
|
|
327
|
+
if meta.get("backup_version", 0) > BACKUP_VERSION:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"Backup version {meta['backup_version']} is newer than "
|
|
330
|
+
f"this Colony supports (max {BACKUP_VERSION})"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
existing_id = _read_colony_id(state_dir)
|
|
334
|
+
backup_id = meta.get("colony_id", "")
|
|
335
|
+
|
|
336
|
+
if existing_id and existing_id != backup_id and not force_identity:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"Backup is from colony {backup_id} but this instance is "
|
|
339
|
+
f"{existing_id}. Use --force-identity to override."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Validate every referenced original before writing any restored state.
|
|
343
|
+
# A caption or an embedding cannot reconstruct the original evidence.
|
|
344
|
+
source_images = _verified_source_images(root)
|
|
345
|
+
governed_restore = root / "databases" / _GOVERNED_ACTION_LEDGER
|
|
346
|
+
try:
|
|
347
|
+
governed_restore.lstat()
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
pass
|
|
350
|
+
else:
|
|
351
|
+
_attest_private_governed_ledger(
|
|
352
|
+
governed_restore, normalize_archive_modes=True
|
|
353
|
+
)
|
|
354
|
+
_preflight_private_governed_destination(state_dir)
|
|
355
|
+
|
|
356
|
+
summary: dict[str, Any] = {
|
|
357
|
+
"colony_id": backup_id, "databases": [], "errors": [],
|
|
358
|
+
"serving_admitted": False,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
identity_dir = root / "identity"
|
|
362
|
+
if identity_dir.is_dir():
|
|
363
|
+
_restore_directory(identity_dir, state_dir)
|
|
364
|
+
summary["identity"] = True
|
|
365
|
+
|
|
366
|
+
db_dir = root / "databases"
|
|
367
|
+
if db_dir.is_dir():
|
|
368
|
+
db_files = list(sorted(db_dir.glob("*.db")))
|
|
369
|
+
governed_ledger = db_dir / _GOVERNED_ACTION_LEDGER
|
|
370
|
+
try:
|
|
371
|
+
governed_ledger.lstat()
|
|
372
|
+
except FileNotFoundError:
|
|
373
|
+
pass
|
|
374
|
+
else:
|
|
375
|
+
_attest_private_governed_ledger(
|
|
376
|
+
governed_ledger, normalize_archive_modes=True
|
|
377
|
+
)
|
|
378
|
+
db_files.append(governed_ledger)
|
|
379
|
+
for db_file in db_files:
|
|
380
|
+
relative = db_file.relative_to(db_dir)
|
|
381
|
+
dest = state_dir / relative
|
|
382
|
+
if relative == _GOVERNED_ACTION_LEDGER:
|
|
383
|
+
_restore_private_governed_ledger(db_file, state_dir)
|
|
384
|
+
else:
|
|
385
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
# A crashed destination can still have committed WAL.
|
|
387
|
+
# SQLite replaces its logical database consistently;
|
|
388
|
+
# copying the main file alone can replay the old WAL over it.
|
|
389
|
+
with closing(sqlite3.connect(db_file.resolve().as_uri() + '?mode=ro', uri=True)) as source:
|
|
390
|
+
with closing(sqlite3.connect(dest)) as target:
|
|
391
|
+
source.backup(target)
|
|
392
|
+
relative_name = relative.as_posix()
|
|
393
|
+
summary["databases"].append(relative_name)
|
|
394
|
+
logger.info("Restored database: %s", relative_name)
|
|
395
|
+
|
|
396
|
+
if source_images:
|
|
397
|
+
from apsimo.vector.image_store import LocalImageStore
|
|
398
|
+
images = LocalImageStore(str(state_dir), source_evidence=True)
|
|
399
|
+
images._ensure_dirs()
|
|
400
|
+
for record, source in source_images:
|
|
401
|
+
destination = images._original_path(record['asset_hash'], record['mime_type'])
|
|
402
|
+
shutil.copyfile(source, destination)
|
|
403
|
+
destination.chmod(0o600)
|
|
404
|
+
summary['source_images'] = len(source_images)
|
|
405
|
+
|
|
406
|
+
config_dir = root / "config"
|
|
407
|
+
if config_dir.is_dir():
|
|
408
|
+
_restore_directory(config_dir, state_dir)
|
|
409
|
+
summary["config"] = True
|
|
410
|
+
|
|
411
|
+
vector_dir = root / "vector"
|
|
412
|
+
if vector_dir.is_dir():
|
|
413
|
+
dest = state_dir / "lancedb"
|
|
414
|
+
if dest.exists():
|
|
415
|
+
shutil.rmtree(dest)
|
|
416
|
+
shutil.copytree(vector_dir / "lancedb", dest, dirs_exist_ok=True)
|
|
417
|
+
summary["vectors"] = True
|
|
418
|
+
|
|
419
|
+
graph_dir = root / "graph"
|
|
420
|
+
if graph_dir.is_dir():
|
|
421
|
+
marker = graph_dir / "SKIPPED.txt"
|
|
422
|
+
if not marker.exists():
|
|
423
|
+
summary["graph_export"] = str(graph_dir / "neo4j-dump.cypher")
|
|
424
|
+
|
|
425
|
+
logger.info("Restore complete: %s", summary)
|
|
426
|
+
return summary
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def restore_source_memory(
|
|
430
|
+
archive_path: str | Path,
|
|
431
|
+
destination: str | Path,
|
|
432
|
+
*,
|
|
433
|
+
current_state: str | Path,
|
|
434
|
+
passphrase: Optional[bytes] = None,
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
"""Recover current canonical memory, without restoring runtime authority.
|
|
437
|
+
|
|
438
|
+
The caller must select a surviving authoritative source ledger. Its current
|
|
439
|
+
membership, revisions, scopes and erasure history replace the old archive's
|
|
440
|
+
memory metadata. The archive supplies only still-owned original image bytes
|
|
441
|
+
missing from that surviving state. No old grants, effects, identity, config,
|
|
442
|
+
contact databases, tasks, graph or native host state are installed.
|
|
443
|
+
"""
|
|
444
|
+
archive_path = Path(archive_path).resolve(strict=True)
|
|
445
|
+
current_state = Path(current_state).resolve()
|
|
446
|
+
destination = Path(destination).absolute()
|
|
447
|
+
resolved_destination = destination.resolve()
|
|
448
|
+
if (destination.exists() or destination.is_symlink()
|
|
449
|
+
or current_state == resolved_destination
|
|
450
|
+
or current_state in resolved_destination.parents
|
|
451
|
+
or resolved_destination in current_state.parents):
|
|
452
|
+
raise ValueError("Memory recovery requires a fresh destination outside current state")
|
|
453
|
+
current_id = _read_colony_id(current_state)
|
|
454
|
+
ledger = current_state / "turn-idempotency.db"
|
|
455
|
+
if not current_id or not ledger.is_file():
|
|
456
|
+
raise ValueError("Memory recovery requires the surviving colony identity and source ledger")
|
|
457
|
+
|
|
458
|
+
from apsimo.vector.image_store import LocalImageStore
|
|
459
|
+
|
|
460
|
+
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
461
|
+
with tempfile.TemporaryDirectory(prefix="colony-memory-recovery-", dir=destination.parent) as temporary:
|
|
462
|
+
staging = Path(temporary)
|
|
463
|
+
actual_archive = archive_path
|
|
464
|
+
if archive_path.suffix == ".enc":
|
|
465
|
+
if passphrase is None:
|
|
466
|
+
raise ValueError("Archive is encrypted; passphrase required")
|
|
467
|
+
actual_archive = staging / "decrypted.tar.gz"
|
|
468
|
+
_decrypt_file(archive_path, actual_archive, passphrase)
|
|
469
|
+
unpacked = staging / "archive"
|
|
470
|
+
unpacked.mkdir()
|
|
471
|
+
_extract_archive(actual_archive, unpacked)
|
|
472
|
+
archived = _find_backup_root(unpacked)
|
|
473
|
+
meta = json.loads((archived / "meta.json").read_text())
|
|
474
|
+
if meta.get("backup_version", 0) > BACKUP_VERSION:
|
|
475
|
+
raise ValueError("Memory archive version is newer than this Colony supports")
|
|
476
|
+
if meta.get("colony_id") != current_id:
|
|
477
|
+
raise ValueError("Memory archive and surviving state have different colony identities")
|
|
478
|
+
|
|
479
|
+
selected = staging / "selected"
|
|
480
|
+
(selected / "databases").mkdir(parents=True)
|
|
481
|
+
snapshot = selected / "databases" / "turn-idempotency.db"
|
|
482
|
+
try:
|
|
483
|
+
with closing(sqlite3.connect(ledger.resolve().as_uri() + "?mode=ro", uri=True)) as source:
|
|
484
|
+
with closing(sqlite3.connect(snapshot)) as target:
|
|
485
|
+
source.backup(target)
|
|
486
|
+
if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
|
487
|
+
raise ValueError("Surviving source ledger failed its integrity check")
|
|
488
|
+
snapshot.chmod(0o600)
|
|
489
|
+
with closing(sqlite3.connect(snapshot.as_uri() + "?mode=ro", uri=True)) as current:
|
|
490
|
+
source_count = current.execute("SELECT count(*) FROM turn_sources").fetchone()[0]
|
|
491
|
+
heads = current.execute(
|
|
492
|
+
"SELECT contact_id,max(sequence) FROM source_erasures GROUP BY contact_id"
|
|
493
|
+
).fetchall()
|
|
494
|
+
_check_erasure_ancestry(archived / "databases" / "turn-idempotency.db", current)
|
|
495
|
+
except sqlite3.Error as error:
|
|
496
|
+
raise ValueError("Surviving canonical source history is unavailable") from error
|
|
497
|
+
if _read_colony_id(current_state) != current_id:
|
|
498
|
+
raise ValueError("Surviving colony identity changed during memory recovery")
|
|
499
|
+
|
|
500
|
+
bundle = staging / "bundle"
|
|
501
|
+
bundle.mkdir(mode=0o700)
|
|
502
|
+
shutil.copyfile(snapshot, bundle / "turn-idempotency.db")
|
|
503
|
+
(bundle / "turn-idempotency.db").chmod(0o600)
|
|
504
|
+
output_images = LocalImageStore(str(bundle), source_evidence=True)
|
|
505
|
+
live_images = LocalImageStore(str(current_state), source_evidence=True)
|
|
506
|
+
old_images = LocalImageStore(str(archived), source_evidence=True)
|
|
507
|
+
recovered_from_archive = 0
|
|
508
|
+
records = _source_image_records(selected)
|
|
509
|
+
if records:
|
|
510
|
+
output_images._ensure_dirs()
|
|
511
|
+
for record in records:
|
|
512
|
+
chosen = None
|
|
513
|
+
for source_images, from_archive in ((live_images, False), (old_images, True)):
|
|
514
|
+
candidate = source_images._original_path(record["asset_hash"], record["mime_type"])
|
|
515
|
+
try:
|
|
516
|
+
data = candidate.read_bytes()
|
|
517
|
+
except FileNotFoundError:
|
|
518
|
+
continue
|
|
519
|
+
if (len(data) != record["size_bytes"]
|
|
520
|
+
or hashlib.sha256(data).hexdigest() != record["asset_hash"]):
|
|
521
|
+
continue
|
|
522
|
+
chosen = data
|
|
523
|
+
recovered_from_archive += int(from_archive)
|
|
524
|
+
break
|
|
525
|
+
if chosen is None:
|
|
526
|
+
raise ValueError("Current source image has no matching original in surviving state or archive")
|
|
527
|
+
target = output_images._original_path(record["asset_hash"], record["mime_type"])
|
|
528
|
+
target.write_bytes(chosen)
|
|
529
|
+
target.chmod(0o600)
|
|
530
|
+
|
|
531
|
+
files = []
|
|
532
|
+
for path in sorted(bundle.rglob("*")):
|
|
533
|
+
if path.is_file():
|
|
534
|
+
files.append({"path": path.relative_to(bundle).as_posix(),
|
|
535
|
+
"sha256": _file_sha256(path), "bytes": path.stat().st_size})
|
|
536
|
+
summary = {
|
|
537
|
+
"recovery_mode": "source_memory_only",
|
|
538
|
+
"colony_id": current_id,
|
|
539
|
+
"source_count": source_count,
|
|
540
|
+
"source_images": len(records),
|
|
541
|
+
"images_recovered_from_archive": recovered_from_archive,
|
|
542
|
+
"erasure_head": max((row[1] for row in heads), default=0),
|
|
543
|
+
"erasure_contacts": len(heads),
|
|
544
|
+
"current_source_ledger_sha256": _file_sha256(snapshot),
|
|
545
|
+
"archive_sha256": _file_sha256(archive_path),
|
|
546
|
+
"files": files,
|
|
547
|
+
"runtime_authority_restored": False,
|
|
548
|
+
"serving_admitted": False,
|
|
549
|
+
}
|
|
550
|
+
receipt = bundle / "source-memory-recovery.json"
|
|
551
|
+
receipt.write_text(json.dumps(summary, indent=2) + "\n")
|
|
552
|
+
receipt.chmod(0o600)
|
|
553
|
+
# Reserve the destination only after all source and image checks pass.
|
|
554
|
+
# A publication failure leaves an incomplete bundle, never a success.
|
|
555
|
+
destination.mkdir(mode=0o700)
|
|
556
|
+
_restore_directory(bundle, destination)
|
|
557
|
+
return summary
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _file_sha256(path: Path) -> str:
|
|
561
|
+
value = hashlib.sha256()
|
|
562
|
+
with path.open("rb") as stream:
|
|
563
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
564
|
+
value.update(block)
|
|
565
|
+
return value.hexdigest()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _check_erasure_ancestry(archived_ledger: Path, current: sqlite3.Connection) -> None:
|
|
569
|
+
"""A selected surviving history cannot rewind or replace captured erasures."""
|
|
570
|
+
if not archived_ledger.is_file():
|
|
571
|
+
return
|
|
572
|
+
with closing(sqlite3.connect(archived_ledger.resolve().as_uri() + "?mode=ro", uri=True)) as old:
|
|
573
|
+
for table in ("source_erasures", "source_erasure_revisions"):
|
|
574
|
+
if not old.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
|
|
575
|
+
continue
|
|
576
|
+
columns = [row[1] for row in old.execute(f"PRAGMA table_info({table})")]
|
|
577
|
+
selected = ",".join('"' + column.replace('"', '""') + '"' for column in columns)
|
|
578
|
+
for row in old.execute(f"SELECT {selected} FROM {table}"):
|
|
579
|
+
sequence = row[columns.index("sequence")]
|
|
580
|
+
current_row = current.execute(
|
|
581
|
+
f"SELECT {selected} FROM {table} WHERE sequence=?", (sequence,)
|
|
582
|
+
).fetchone()
|
|
583
|
+
if current_row != row:
|
|
584
|
+
raise ValueError("Surviving source erasure history does not extend the archive")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ── Database snapshot ────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _snapshot_databases(
|
|
591
|
+
state_dir: Path, dest: Path,
|
|
592
|
+
) -> list[dict[str, str]]:
|
|
593
|
+
"""Snapshot top-level databases and the private action ledger."""
|
|
594
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
595
|
+
manifest: list[dict[str, str]] = []
|
|
596
|
+
|
|
597
|
+
db_paths = list(sorted(state_dir.glob("*.db")))
|
|
598
|
+
governed_ledger = state_dir / _GOVERNED_ACTION_LEDGER
|
|
599
|
+
try:
|
|
600
|
+
governed_ledger.lstat()
|
|
601
|
+
except FileNotFoundError:
|
|
602
|
+
pass
|
|
603
|
+
else:
|
|
604
|
+
_attest_private_governed_ledger(governed_ledger)
|
|
605
|
+
db_paths.append(governed_ledger)
|
|
606
|
+
|
|
607
|
+
for db_path in db_paths:
|
|
608
|
+
relative = db_path.relative_to(state_dir)
|
|
609
|
+
relative_name = relative.as_posix()
|
|
610
|
+
snap_path = dest / relative
|
|
611
|
+
snap_path.parent.mkdir(parents=True, exist_ok=True)
|
|
612
|
+
try:
|
|
613
|
+
conn = sqlite3.connect(str(db_path))
|
|
614
|
+
try:
|
|
615
|
+
escaped_snap_path = str(snap_path).replace("'", "''")
|
|
616
|
+
conn.execute(f"VACUUM INTO '{escaped_snap_path}'")
|
|
617
|
+
finally:
|
|
618
|
+
conn.close()
|
|
619
|
+
if relative == _GOVERNED_ACTION_LEDGER:
|
|
620
|
+
snap_path.parent.chmod(0o700)
|
|
621
|
+
snap_path.chmod(0o600)
|
|
622
|
+
manifest.append({
|
|
623
|
+
"filename": relative_name,
|
|
624
|
+
"size_bytes": str(snap_path.stat().st_size),
|
|
625
|
+
})
|
|
626
|
+
logger.info("Snapshotted database: %s", relative_name)
|
|
627
|
+
except Exception as exc:
|
|
628
|
+
if relative == _GOVERNED_ACTION_LEDGER:
|
|
629
|
+
try:
|
|
630
|
+
snap_path.unlink(missing_ok=True)
|
|
631
|
+
except OSError:
|
|
632
|
+
logger.error(
|
|
633
|
+
"Failed to remove incomplete governed action ledger snapshot",
|
|
634
|
+
exc_info=True,
|
|
635
|
+
)
|
|
636
|
+
raise RuntimeError(
|
|
637
|
+
"governed action ledger could not be snapshotted consistently"
|
|
638
|
+
) from exc
|
|
639
|
+
logger.warning("VACUUM snapshot failed for %s; trying SQLite backup", relative_name)
|
|
640
|
+
try:
|
|
641
|
+
# Copying only the main file can lose committed WAL records.
|
|
642
|
+
snap_path.unlink(missing_ok=True)
|
|
643
|
+
with closing(sqlite3.connect(db_path.resolve().as_uri() + '?mode=ro', uri=True)) as source:
|
|
644
|
+
with closing(sqlite3.connect(snap_path)) as target:
|
|
645
|
+
source.backup(target)
|
|
646
|
+
manifest.append({
|
|
647
|
+
"filename": relative_name,
|
|
648
|
+
"size_bytes": str(snap_path.stat().st_size),
|
|
649
|
+
"method": "sqlite_backup",
|
|
650
|
+
})
|
|
651
|
+
except Exception as exc2:
|
|
652
|
+
raise RuntimeError(f"Database could not be snapshotted consistently: {relative_name}") from exc2
|
|
653
|
+
|
|
654
|
+
return manifest
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _source_image_records(root: Path) -> list[dict]:
|
|
658
|
+
"""Read asset ownership from the captured database, never a later live view."""
|
|
659
|
+
ledger = root / 'databases' / 'turn-idempotency.db'
|
|
660
|
+
if not ledger.is_file():
|
|
661
|
+
return []
|
|
662
|
+
conn = sqlite3.connect(ledger.resolve().as_uri() + '?mode=ro', uri=True)
|
|
663
|
+
try:
|
|
664
|
+
conn.row_factory = sqlite3.Row
|
|
665
|
+
if not conn.execute("SELECT 1 FROM sqlite_master WHERE name='source_media'").fetchone():
|
|
666
|
+
return []
|
|
667
|
+
rows = conn.execute('''SELECT DISTINCT m.asset_hash,m.mime_type,m.size_bytes
|
|
668
|
+
FROM source_media m JOIN source_media_links l ON l.asset_hash=m.asset_hash
|
|
669
|
+
JOIN turn_sources s ON s.turn_id=l.turn_id WHERE m.status != 'orphan' ''').fetchall()
|
|
670
|
+
records = [dict(row) for row in rows]
|
|
671
|
+
for row in records:
|
|
672
|
+
if not re.fullmatch('[0-9a-f]{64}', row['asset_hash']):
|
|
673
|
+
raise ValueError('Invalid original source image identifier')
|
|
674
|
+
return records
|
|
675
|
+
finally:
|
|
676
|
+
conn.close()
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _verified_source_images(root: Path) -> list[tuple[dict, Path]]:
|
|
680
|
+
from apsimo.vector.image_store import LocalImageStore
|
|
681
|
+
images = LocalImageStore(str(root), source_evidence=True)
|
|
682
|
+
result = []
|
|
683
|
+
for record in _source_image_records(root):
|
|
684
|
+
path = images._original_path(record['asset_hash'], record['mime_type'])
|
|
685
|
+
try:
|
|
686
|
+
data = path.read_bytes()
|
|
687
|
+
except OSError as exc:
|
|
688
|
+
raise ValueError('Backup is missing an original source image') from exc
|
|
689
|
+
if len(data) != record['size_bytes'] or hashlib.sha256(data).hexdigest() != record['asset_hash']:
|
|
690
|
+
raise ValueError('Backup original source image does not match its evidence hash')
|
|
691
|
+
result.append((record, path))
|
|
692
|
+
return result
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _snapshot_source_images(state_dir: Path, staging: Path) -> int:
|
|
696
|
+
from apsimo.vector.image_store import LocalImageStore
|
|
697
|
+
source = LocalImageStore(str(state_dir), source_evidence=True)
|
|
698
|
+
target = LocalImageStore(str(staging), source_evidence=True)
|
|
699
|
+
records = _source_image_records(staging)
|
|
700
|
+
if records:
|
|
701
|
+
target._ensure_dirs()
|
|
702
|
+
for record in records:
|
|
703
|
+
try:
|
|
704
|
+
shutil.copyfile(source._original_path(record['asset_hash'], record['mime_type']),
|
|
705
|
+
target._original_path(record['asset_hash'], record['mime_type']))
|
|
706
|
+
except OSError as exc:
|
|
707
|
+
# Concurrent erasure can remove an original after the DB snapshot.
|
|
708
|
+
# Retry the backup later instead of publishing a broken archive.
|
|
709
|
+
raise RuntimeError('Original source image changed during backup; retry') from exc
|
|
710
|
+
return len(_verified_source_images(staging))
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ── Identity snapshot ────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _snapshot_identity(state_dir: Path, dest: Path) -> None:
|
|
717
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
718
|
+
identity_files = ["colony-id", "genesis.json"]
|
|
719
|
+
keys_dir = state_dir / "colony-keys"
|
|
720
|
+
|
|
721
|
+
for f in identity_files:
|
|
722
|
+
src = state_dir / f
|
|
723
|
+
if src.exists():
|
|
724
|
+
shutil.copy2(src, dest / f)
|
|
725
|
+
|
|
726
|
+
if keys_dir.is_dir():
|
|
727
|
+
dest_keys = dest / "colony-keys"
|
|
728
|
+
dest_keys.mkdir(exist_ok=True)
|
|
729
|
+
for key_file in keys_dir.iterdir():
|
|
730
|
+
shutil.copy2(key_file, dest_keys / key_file.name)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# ── Config snapshot (with secret scrubbing) ──────────────────────────────
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _snapshot_config(state_dir: Path, dest: Path) -> None:
|
|
737
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
config_files = [
|
|
739
|
+
".env", "channels.json", ".colony-llm-config.json",
|
|
740
|
+
"standing_approvals.json",
|
|
741
|
+
]
|
|
742
|
+
|
|
743
|
+
for name in config_files:
|
|
744
|
+
src = state_dir / name
|
|
745
|
+
if not src.exists():
|
|
746
|
+
src = state_dir.parent / name
|
|
747
|
+
if src.exists():
|
|
748
|
+
if name == ".env":
|
|
749
|
+
scrubbed = _scrub_env_file(src.read_text())
|
|
750
|
+
(dest / name).write_text(scrubbed)
|
|
751
|
+
else:
|
|
752
|
+
shutil.copy2(src, dest / name)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _scrub_env_file(content: str) -> str:
|
|
756
|
+
lines = []
|
|
757
|
+
for line in content.splitlines():
|
|
758
|
+
stripped = line.strip()
|
|
759
|
+
if not stripped or stripped.startswith("#"):
|
|
760
|
+
lines.append(line)
|
|
761
|
+
continue
|
|
762
|
+
if "=" in stripped:
|
|
763
|
+
key, _, _ = stripped.partition("=")
|
|
764
|
+
if _SECRET_KEY_PATTERN.search(key.strip()):
|
|
765
|
+
lines.append(f"{key.strip()}=<REDACTED>")
|
|
766
|
+
continue
|
|
767
|
+
lines.append(line)
|
|
768
|
+
return "\n".join(lines) + "\n"
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# ── Vector store snapshot ────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _snapshot_vectors(state_dir: Path, dest: Path) -> None:
|
|
775
|
+
lance_dir = state_dir / "lancedb"
|
|
776
|
+
if not lance_dir.is_dir():
|
|
777
|
+
return
|
|
778
|
+
dest_lance = dest / "lancedb"
|
|
779
|
+
shutil.copytree(lance_dir, dest_lance)
|
|
780
|
+
logger.info("Snapshotted LanceDB directory")
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# ── Graph export ─────────────────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _snapshot_graph(state_dir: Path, dest: Path) -> str:
|
|
787
|
+
"""Export Neo4j graph as Cypher. Returns status string."""
|
|
788
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
789
|
+
|
|
790
|
+
neo4j_uri = os.environ.get("NEO4J_URI", "")
|
|
791
|
+
if not neo4j_uri:
|
|
792
|
+
(dest / "SKIPPED.txt").write_text(
|
|
793
|
+
"Neo4j not configured (NEO4J_URI not set)\n"
|
|
794
|
+
)
|
|
795
|
+
return "skipped_not_configured"
|
|
796
|
+
|
|
797
|
+
try:
|
|
798
|
+
from neo4j import GraphDatabase
|
|
799
|
+
|
|
800
|
+
neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
|
801
|
+
neo4j_pass = os.environ.get("NEO4J_PASSWORD", "")
|
|
802
|
+
|
|
803
|
+
driver = GraphDatabase.driver(
|
|
804
|
+
neo4j_uri, auth=(neo4j_user, neo4j_pass)
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
cypher_stmts: list[str] = []
|
|
808
|
+
with driver.session() as session:
|
|
809
|
+
nodes = session.run("MATCH (n) RETURN n")
|
|
810
|
+
for record in nodes:
|
|
811
|
+
node = record["n"]
|
|
812
|
+
labels = ":".join(node.labels)
|
|
813
|
+
props = json.dumps(dict(node), default=str)
|
|
814
|
+
cypher_stmts.append(
|
|
815
|
+
f"CREATE (:{labels} {props});"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
rels = session.run(
|
|
819
|
+
"MATCH (a)-[r]->(b) "
|
|
820
|
+
"RETURN id(a) as a_id, type(r) as rel_type, "
|
|
821
|
+
"properties(r) as props, id(b) as b_id"
|
|
822
|
+
)
|
|
823
|
+
for record in rels:
|
|
824
|
+
props = json.dumps(record["props"], default=str)
|
|
825
|
+
cypher_stmts.append(
|
|
826
|
+
f"// REL: ({record['a_id']})-[:{record['rel_type']} {props}]->({record['b_id']})"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
driver.close()
|
|
830
|
+
|
|
831
|
+
output = dest / "neo4j-dump.cypher"
|
|
832
|
+
output.write_text("\n".join(cypher_stmts) + "\n")
|
|
833
|
+
logger.info("Exported %d graph statements", len(cypher_stmts))
|
|
834
|
+
return "exported"
|
|
835
|
+
|
|
836
|
+
except ImportError:
|
|
837
|
+
(dest / "SKIPPED.txt").write_text("neo4j driver not installed\n")
|
|
838
|
+
return "skipped_no_driver"
|
|
839
|
+
except Exception as exc:
|
|
840
|
+
logger.warning("Neo4j export failed: %s", exc)
|
|
841
|
+
(dest / "SKIPPED.txt").write_text(f"Export failed: {exc}\n")
|
|
842
|
+
return "skipped_error"
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ── Host state ───────────────────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _snapshot_host_state(paths: list[str], dest: Path) -> None:
|
|
849
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
850
|
+
for p in paths:
|
|
851
|
+
src = Path(p).expanduser()
|
|
852
|
+
if src.is_file():
|
|
853
|
+
shutil.copy2(src, dest / src.name)
|
|
854
|
+
elif src.is_dir():
|
|
855
|
+
shutil.copytree(src, dest / src.name, dirs_exist_ok=True)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
# ── Archive creation / extraction ────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _create_archive(source_dir: Path, archive_path: Path) -> None:
|
|
862
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
863
|
+
tar.add(source_dir, arcname=source_dir.name)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _extract_archive(archive_path: Path, dest: Path) -> None:
|
|
867
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
868
|
+
tar.extractall(dest, filter="data")
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _find_backup_root(staging: Path) -> Path:
|
|
872
|
+
"""Find the root directory inside the extracted archive."""
|
|
873
|
+
entries = list(staging.iterdir())
|
|
874
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
875
|
+
return entries[0]
|
|
876
|
+
return staging
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ── Encryption (AES-256-GCM) ────────────────────────────────────────────
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _encrypt_file(
|
|
883
|
+
src: Path, dest: Path, passphrase: bytes,
|
|
884
|
+
) -> None:
|
|
885
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
886
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
887
|
+
|
|
888
|
+
salt = os.urandom(16)
|
|
889
|
+
kdf = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
|
|
890
|
+
key = kdf.derive(passphrase)
|
|
891
|
+
|
|
892
|
+
nonce = os.urandom(12)
|
|
893
|
+
aesgcm = AESGCM(key)
|
|
894
|
+
plaintext = src.read_bytes()
|
|
895
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
|
896
|
+
|
|
897
|
+
with dest.open("wb") as f:
|
|
898
|
+
f.write(b"COLONY_ENC_V1\n")
|
|
899
|
+
f.write(salt)
|
|
900
|
+
f.write(nonce)
|
|
901
|
+
f.write(ciphertext)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _decrypt_file(
|
|
905
|
+
src: Path, dest: Path, passphrase: bytes,
|
|
906
|
+
) -> None:
|
|
907
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
908
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
909
|
+
|
|
910
|
+
data = src.read_bytes()
|
|
911
|
+
header = b"COLONY_ENC_V1\n"
|
|
912
|
+
if not data.startswith(header):
|
|
913
|
+
raise ValueError("Not a Colony encrypted backup (invalid header)")
|
|
914
|
+
|
|
915
|
+
offset = len(header)
|
|
916
|
+
salt = data[offset:offset + 16]
|
|
917
|
+
nonce = data[offset + 16:offset + 28]
|
|
918
|
+
ciphertext = data[offset + 28:]
|
|
919
|
+
|
|
920
|
+
kdf = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
|
|
921
|
+
key = kdf.derive(passphrase)
|
|
922
|
+
|
|
923
|
+
aesgcm = AESGCM(key)
|
|
924
|
+
try:
|
|
925
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
|
926
|
+
except Exception:
|
|
927
|
+
raise ValueError("Decryption failed -- wrong passphrase?")
|
|
928
|
+
|
|
929
|
+
dest.write_bytes(plaintext)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _read_colony_id(state_dir: Path) -> str:
|
|
936
|
+
id_path = state_dir / "colony-id"
|
|
937
|
+
if id_path.exists():
|
|
938
|
+
return id_path.read_text().strip()
|
|
939
|
+
return ""
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _compute_identity_hmac(colony_id: str) -> str:
|
|
943
|
+
return hmac.new(
|
|
944
|
+
colony_id.encode(), b"colony-backup-binding", hashlib.sha256
|
|
945
|
+
).hexdigest()
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _get_colony_version() -> str:
|
|
949
|
+
try:
|
|
950
|
+
from importlib.metadata import version
|
|
951
|
+
return version("colonyai")
|
|
952
|
+
except Exception:
|
|
953
|
+
return "unknown"
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _restore_directory(src: Path, dest: Path) -> None:
|
|
957
|
+
for item in src.rglob("*"):
|
|
958
|
+
if item.is_file():
|
|
959
|
+
rel = item.relative_to(src)
|
|
960
|
+
target = dest / rel
|
|
961
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
962
|
+
shutil.copy2(item, target)
|