activegraph 1.4.0__tar.gz → 1.5.0__tar.gz

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.
Files changed (191) hide show
  1. {activegraph-1.4.0/activegraph.egg-info → activegraph-1.5.0}/PKG-INFO +1 -1
  2. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/__init__.py +1 -1
  3. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/runtime.py +80 -0
  4. activegraph-1.5.0/activegraph/sandbox/__init__.py +257 -0
  5. activegraph-1.5.0/activegraph/sandbox/_child.py +234 -0
  6. activegraph-1.5.0/activegraph/store/retention.py +316 -0
  7. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/sqlite.py +126 -0
  8. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/trace/printer.py +12 -0
  9. {activegraph-1.4.0 → activegraph-1.5.0/activegraph.egg-info}/PKG-INFO +1 -1
  10. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph.egg-info/SOURCES.txt +5 -0
  11. {activegraph-1.4.0 → activegraph-1.5.0}/pyproject.toml +1 -1
  12. activegraph-1.5.0/tests/test_compaction.py +249 -0
  13. activegraph-1.5.0/tests/test_sandbox_trial.py +260 -0
  14. {activegraph-1.4.0 → activegraph-1.5.0}/LICENSE +0 -0
  15. {activegraph-1.4.0 → activegraph-1.5.0}/NOTICE +0 -0
  16. {activegraph-1.4.0 → activegraph-1.5.0}/README.md +0 -0
  17. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/__main__.py +0 -0
  18. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/_signature.py +0 -0
  19. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/behaviors/__init__.py +0 -0
  20. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/behaviors/base.py +0 -0
  21. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/behaviors/decorators.py +0 -0
  22. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/cli/__init__.py +0 -0
  23. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/cli/main.py +0 -0
  24. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/cli/quickstart.py +0 -0
  25. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/cli/renderers.py +0 -0
  26. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/__init__.py +0 -0
  27. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/clock.py +0 -0
  28. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/event.py +0 -0
  29. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/graph.py +0 -0
  30. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/graph_store.py +0 -0
  31. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/ids.py +0 -0
  32. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/patch.py +0 -0
  33. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/core/view.py +0 -0
  34. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/errors.py +0 -0
  35. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/frame.py +0 -0
  36. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/__init__.py +0 -0
  37. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/anthropic.py +0 -0
  38. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/cache.py +0 -0
  39. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/embedding.py +0 -0
  40. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/errors.py +0 -0
  41. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/native.py +0 -0
  42. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/openai.py +0 -0
  43. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/parsing.py +0 -0
  44. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/prompt.py +0 -0
  45. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/provider.py +0 -0
  46. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/recorded.py +0 -0
  47. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/types.py +0 -0
  48. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/llm/wire.py +0 -0
  49. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/__init__.py +0 -0
  50. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/logging.py +0 -0
  51. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/metrics.py +0 -0
  52. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/migration.py +0 -0
  53. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/otel.py +0 -0
  54. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/prometheus.py +0 -0
  55. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/observability/status.py +0 -0
  56. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/__init__.py +0 -0
  57. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/__init__.py +0 -0
  58. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/behaviors.py +0 -0
  59. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  60. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  61. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/object_types.py +0 -0
  62. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  63. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  64. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  65. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  66. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/settings.py +0 -0
  67. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/diligence/tools.py +0 -0
  68. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/loader.py +0 -0
  69. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/manifest.py +0 -0
  70. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/packs/scaffold.py +0 -0
  71. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/policy.py +0 -0
  72. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/__init__.py +0 -0
  73. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/_live.py +0 -0
  74. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/behavior_graph.py +0 -0
  75. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/budget.py +0 -0
  76. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/config_errors.py +0 -0
  77. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/diff.py +0 -0
  78. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/errors.py +0 -0
  79. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/exec_errors.py +0 -0
  80. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/patterns.py +0 -0
  81. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/promote.py +0 -0
  82. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/queue.py +0 -0
  83. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/registration_errors.py +0 -0
  84. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/registry.py +0 -0
  85. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/scheduler.py +0 -0
  86. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/runtime/view_builder.py +0 -0
  87. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/__init__.py +0 -0
  88. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/base.py +0 -0
  89. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/conformance.py +0 -0
  90. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/errors.py +0 -0
  91. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/falkordb.py +0 -0
  92. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/graph_conformance.py +0 -0
  93. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/memory.py +0 -0
  94. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/postgres.py +0 -0
  95. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/serde.py +0 -0
  96. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/store/url.py +0 -0
  97. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/__init__.py +0 -0
  98. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/base.py +0 -0
  99. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/cache.py +0 -0
  100. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/context.py +0 -0
  101. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/decorators.py +0 -0
  102. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/errors.py +0 -0
  103. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/graph_query.py +0 -0
  104. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/recorded.py +0 -0
  105. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/tools/web_fetch.py +0 -0
  106. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/trace/__init__.py +0 -0
  107. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph/trace/causal.py +0 -0
  108. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph.egg-info/dependency_links.txt +0 -0
  109. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph.egg-info/entry_points.txt +0 -0
  110. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph.egg-info/requires.txt +0 -0
  111. {activegraph-1.4.0 → activegraph-1.5.0}/activegraph.egg-info/top_level.txt +0 -0
  112. {activegraph-1.4.0 → activegraph-1.5.0}/setup.cfg +0 -0
  113. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_activate_after.py +0 -0
  114. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_causal_cross_tool.py +0 -0
  115. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_cli.py +0 -0
  116. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_cli_docs_flags.py +0 -0
  117. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_clock.py +0 -0
  118. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_diff.py +0 -0
  119. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_diligence_pack.py +0 -0
  120. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_diligence_with_tools.py +0 -0
  121. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_disable_pack.py +0 -0
  122. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_doc_links.py +0 -0
  123. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_doc_python_snippets.py +0 -0
  124. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_doc_site_reachable.py +0 -0
  125. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_embedding_provider.py +0 -0
  126. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_errors_format.py +0 -0
  127. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_event.py +0 -0
  128. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_falkordb_store.py +0 -0
  129. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_fork.py +0 -0
  130. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_graph.py +0 -0
  131. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_graph_store.py +0 -0
  132. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_ids.py +0 -0
  133. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_license.py +0 -0
  134. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_anthropic.py +0 -0
  135. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_behavior.py +0 -0
  136. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_budget.py +0 -0
  137. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_causal.py +0 -0
  138. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_claim_extraction.py +0 -0
  139. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_default_model.py +0 -0
  140. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_determinism.py +0 -0
  141. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_failure.py +0 -0
  142. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_native_structured_output.py +0 -0
  143. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_openai.py +0 -0
  144. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_prompt.py +0 -0
  145. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_provider_fixtures.py +0 -0
  146. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_replay.py +0 -0
  147. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_tool_loop.py +0 -0
  148. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_trace.py +0 -0
  149. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_trace_snapshot.py +0 -0
  150. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_types.py +0 -0
  151. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llm_wire.py +0 -0
  152. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_llms_txt.py +0 -0
  153. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_migration.py +0 -0
  154. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_observability_logging.py +0 -0
  155. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_observability_metrics.py +0 -0
  156. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_operate_example.py +0 -0
  157. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_pack_manifest.py +0 -0
  158. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_pack_scaffold.py +0 -0
  159. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_packs.py +0 -0
  160. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_patch.py +0 -0
  161. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_pattern_matcher.py +0 -0
  162. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_pattern_parser.py +0 -0
  163. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_pattern_subscriptions.py +0 -0
  164. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_persistence.py +0 -0
  165. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_postgres_store.py +0 -0
  166. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_promote.py +0 -0
  167. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_quickstart.py +0 -0
  168. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_quickstart_snapshot.py +0 -0
  169. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_reason_codes_docs.py +0 -0
  170. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_registration_validation.py +0 -0
  171. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_replay.py +0 -0
  172. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_replay_trace_snapshot.py +0 -0
  173. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_requeue_unfired.py +0 -0
  174. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_resume_example.py +0 -0
  175. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_runtime.py +0 -0
  176. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_runtime_status.py +0 -0
  177. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_serde.py +0 -0
  178. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_store_conformance.py +0 -0
  179. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_store_url.py +0 -0
  180. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_tool_replay.py +0 -0
  181. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_tool_trace_snapshot.py +0 -0
  182. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_tools.py +0 -0
  183. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_trace.py +0 -0
  184. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_trace_accessors.py +0 -0
  185. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_tutorial_snippets.py +0 -0
  186. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_v1_0_1_patches.py +0 -0
  187. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
  188. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_v1_0_3_tool_multiturn.py +0 -0
  189. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_version_sync.py +0 -0
  190. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_view.py +0 -0
  191. {activegraph-1.4.0 → activegraph-1.5.0}/tests/test_wheel_completeness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License-Expression: Apache-2.0
@@ -242,4 +242,4 @@ __all__ = [
242
242
  "tool",
243
243
  ]
244
244
 
245
- __version__ = "1.4.0"
245
+ __version__ = "1.5.0"
@@ -2550,6 +2550,11 @@ class Runtime:
2550
2550
  store = _open_sqlite_store(path, run_id=chosen)
2551
2551
  graph = Graph(ids=IDGen(), run_id=chosen, graph_store=graph_store)
2552
2552
  events = list(store.iter_events())
2553
+ # CONTRACT v1.5 #2: a compacted run's hot log begins with its
2554
+ # runtime.snapshot event; project the snapshot state first so
2555
+ # the suffix replays against the base it was recorded on.
2556
+ if events and events[0].type == "runtime.snapshot":
2557
+ _materialize_snapshot(graph, store, events[0])
2553
2558
  for ev in events:
2554
2559
  graph._replay_event(ev) # noqa: SLF001 — internal seam
2555
2560
  graph.ids.reseed_from_events(events)
@@ -2707,6 +2712,11 @@ class Runtime:
2707
2712
  fork_store = SQLiteEventStore(store.path, run_id=new_run_id)
2708
2713
  fork_graph = Graph(ids=IDGen(), run_id=new_run_id, graph_store=graph_store)
2709
2714
  events = list(fork_store.iter_events())
2715
+ # CONTRACT v1.5 #2: forking a compacted parent copies its
2716
+ # runtime.snapshot event; the fork's base state comes from the
2717
+ # shared snapshot sidecar exactly as a load would.
2718
+ if events and events[0].type == "runtime.snapshot":
2719
+ _materialize_snapshot(fork_graph, fork_store, events[0])
2710
2720
  for ev in events:
2711
2721
  fork_graph._replay_event(ev) # noqa: SLF001
2712
2722
  fork_graph.ids.reseed_from_events(events)
@@ -3416,6 +3426,13 @@ def _verify_replay(
3416
3426
  cache = LLMCache.from_events(recorded_events)
3417
3427
 
3418
3428
  fresh = Graph(ids=IDGen(), run_id="verify_" + loaded_graph.run_id)
3429
+ # CONTRACT v1.5 #2: compacted runs verify their post-snapshot
3430
+ # suffix against the snapshot base; the archived prefix is
3431
+ # verified separately by store.retention.verify_snapshot.
3432
+ if recorded_events and recorded_events[0].type == "runtime.snapshot":
3433
+ _materialize_snapshot(
3434
+ fresh, loaded_graph.store, recorded_events[0]
3435
+ )
3419
3436
  fresh_rt = Runtime(
3420
3437
  fresh,
3421
3438
  behaviors=behaviors,
@@ -3538,6 +3555,69 @@ def _is_lifecycle(e: Event) -> bool:
3538
3555
  )
3539
3556
 
3540
3557
 
3558
+ def _materialize_snapshot(graph: Graph, store: Any, snapshot_event: Event) -> None:
3559
+ """Rebuild a compacted run's base state from its snapshot blob.
3560
+
3561
+ CONTRACT v1.5 #2: the blob is fetched from the store's snapshot
3562
+ sidecar by the hash recorded in the ``runtime.snapshot`` event,
3563
+ verified against that hash (fail-loud on mismatch — a corrupted
3564
+ sidecar must never silently produce wrong state), and projected
3565
+ into the graph before the post-snapshot suffix replays. Id
3566
+ counters recorded at compact time prime the generators so fresh
3567
+ mints can't collide with archived history.
3568
+ """
3569
+ from activegraph.core.graph import Object, Relation
3570
+ from activegraph.store.retention import (
3571
+ SnapshotIntegrityError,
3572
+ state_hash_of,
3573
+ )
3574
+
3575
+ payload = snapshot_event.payload or {}
3576
+ expected = str(payload.get("state_hash", ""))
3577
+ blob = store.get_snapshot(expected) if store is not None else None
3578
+ if blob is None:
3579
+ raise SnapshotIntegrityError(
3580
+ run_id=graph.run_id,
3581
+ expected=expected,
3582
+ detail="the snapshot blob is missing from the sidecar table.",
3583
+ )
3584
+ if state_hash_of(blob) != expected:
3585
+ raise SnapshotIntegrityError(
3586
+ run_id=graph.run_id,
3587
+ expected=expected,
3588
+ detail="the stored blob does not hash to the recorded state hash.",
3589
+ )
3590
+ state = json.loads(blob)
3591
+ for o in state.get("objects", []):
3592
+ graph._state.put_object( # noqa: SLF001 — the replay seam
3593
+ Object(
3594
+ id=o["id"],
3595
+ type=o["type"],
3596
+ data=o["data"],
3597
+ version=o.get("version", 1),
3598
+ provenance=o.get("provenance", {}),
3599
+ )
3600
+ )
3601
+ for r in state.get("relations", []):
3602
+ graph._state.put_relation( # noqa: SLF001
3603
+ Relation(
3604
+ id=r["id"],
3605
+ source=r["source"],
3606
+ target=r["target"],
3607
+ type=r["type"],
3608
+ data=r.get("data", {}),
3609
+ provenance=r.get("provenance", {}),
3610
+ )
3611
+ )
3612
+ counters = payload.get("id_counters", {})
3613
+ ids = graph.ids
3614
+ ids._object_counter = max(ids._object_counter, int(counters.get("object", 0))) # noqa: SLF001
3615
+ ids._event_counter = max(ids._event_counter, int(counters.get("event", 0))) # noqa: SLF001
3616
+ ids._relation_counter = max(ids._relation_counter, int(counters.get("relation", 0))) # noqa: SLF001
3617
+ ids._patch_counter = max(ids._patch_counter, int(counters.get("patch", 0))) # noqa: SLF001
3618
+ ids._frame_counter = max(ids._frame_counter, int(counters.get("frame", 0))) # noqa: SLF001
3619
+
3620
+
3541
3621
  def _rebuild_pending_approvals(rt: "Runtime", events: list[Event]) -> None:
3542
3622
  """Reconstruct the pending-approval queue from the event log. v1.4.
3543
3623
 
@@ -0,0 +1,257 @@
1
+ """Subprocess fork-trial isolation. CONTRACT v1.5 #1.
2
+
3
+ Implements ``trial-isolation-design.md``: candidate pack code trials
4
+ run in a **fresh interpreter**, against a fork of the parent run,
5
+ materialized from artifacts pinned by the bundle hash — so the parent
6
+ process is out of the blast radius of the T1 accident class (runaway
7
+ memory/CPU, corrupted in-process state) and the bytes trialed are the
8
+ bytes the proposal recorded.
9
+
10
+ The division of authority is the design's:
11
+
12
+ * **The parent forks.** The child receives only the fork's run id —
13
+ it never gets fork authority, and it appends only to that run.
14
+ * **The child is a fresh interpreter** (``sys.executable -m
15
+ activegraph.sandbox._child``), not ``os.fork()`` — no inherited
16
+ Python state, no shared clients. Environment is allow-list only.
17
+ * **Three independent nets**: ``resource.setrlimit`` (address
18
+ space + CPU, POSIX; degrade to the other two nets on Windows),
19
+ parent-side wall-clock kill, and the runtime's own event/LLM
20
+ budgets inside the child.
21
+ * **The store is the record.** The child's stdout tail is a signal;
22
+ the parent re-reads the fork run from the store and the store's
23
+ numbers win any disagreement.
24
+
25
+ HONEST LIMITS (the design's §5, restated where users will read it):
26
+ this is crash/state isolation, **not a security sandbox**. A fresh
27
+ interpreter with rlimits does not stop a malicious candidate from
28
+ opening sockets or reading the filesystem — syscall and network
29
+ confinement are host territory (containers, seccomp), and this module
30
+ does not imitate them. The child can also, in principle, open the
31
+ shared SQLite file directly and touch other runs; per-run
32
+ authorization inside one SQLite file is not something SQLite gives us
33
+ honestly, so that caveat is stated rather than solved. The evolution
34
+ pack's static gates remain the pre-execution filter.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import sys
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Optional
46
+
47
+
48
+ #: The closed outcome set. ``crashed`` is the implementation's one
49
+ #: addition over the design draft: a child that dies without a
50
+ #: parseable tail (segfault, os._exit, kill) is reported as what it
51
+ #: is, never guessed into a neater bucket.
52
+ TRIAL_OUTCOMES = (
53
+ "completed",
54
+ "scenario_failed",
55
+ "limits_exceeded",
56
+ "materialization_failed",
57
+ "crashed",
58
+ )
59
+
60
+ # Child exit codes, mirrored in _child.py. Anything else → crashed.
61
+ _EXIT_TO_OUTCOME = {
62
+ 0: "completed",
63
+ 30: "scenario_failed",
64
+ 40: "limits_exceeded",
65
+ 50: "materialization_failed",
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PackSource:
71
+ """Where the child materializes the candidate pack from.
72
+
73
+ ``expected_bundle_hash`` is the external pin (the §4 walk WITH
74
+ ``manifest.toml``, per the v1.4 bundle-hash amendment) — the child
75
+ verifies it via ``activegraph.packs.manifest.verify_bundle_hash``
76
+ before importing anything, so the bytes trialed are the bytes the
77
+ proposal recorded. ``manifest_required=True`` additionally runs
78
+ ``load_manifest`` + ``verify_surface`` against the live ``Pack``,
79
+ making the trial child the first consumer of the full manifest
80
+ chain end-to-end.
81
+ """
82
+
83
+ root_dir: str
84
+ expected_bundle_hash: str = ""
85
+ manifest_required: bool = True
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class TrialLimits:
90
+ """The trial's resource nets. Zero/None disables a given net.
91
+
92
+ ``max_llm_calls=0`` (the default) means key-freedom is
93
+ STRUCTURAL: the child configures no LLM provider at all, so a
94
+ candidate with LLM behaviors fails loud at registration
95
+ (``MissingProviderError``) rather than reaching a network. A
96
+ positive cap is accepted and recorded for a future
97
+ provider-wiring seam; the v1 child never configures a live
98
+ provider either way. ``env_passthrough`` is the ONLY parent environment
99
+ forwarded beyond interpreter mechanics (PATH/PYTHONPATH) — the
100
+ helper never forwards the environment wholesale, so parent API
101
+ keys don't leak into candidate code by default.
102
+ """
103
+
104
+ wall_clock_seconds: float = 120.0
105
+ max_rss_bytes: Optional[int] = None
106
+ max_events: Optional[int] = 2_000
107
+ max_llm_calls: Optional[int] = 0
108
+ env_passthrough: tuple[str, ...] = ()
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class TrialReport:
113
+ """What a trial produced. Store-derived numbers, child-signaled shape.
114
+
115
+ ``events_appended`` and ``behavior_failures`` are re-read from the
116
+ fork's run in the store by the parent AFTER the child exits — the
117
+ stdout tail is a signal only, and any disagreement resolves in
118
+ favor of the store. Richer evidence (tracebacks, diffs) is read
119
+ from the fork's log directly: ``Runtime.load(store, run_id=
120
+ report.fork_run_id)`` then ``trace.failures()`` / ``diff()``.
121
+ """
122
+
123
+ outcome: str
124
+ fork_run_id: str
125
+ events_appended: int
126
+ behavior_failures: int
127
+ detail: str
128
+ exit_code: Optional[int]
129
+
130
+
131
+ def run_forked_trial(
132
+ store_path: str,
133
+ *,
134
+ parent_run_id: str,
135
+ at_event: str,
136
+ pack_source: PackSource,
137
+ scenario: str = "",
138
+ limits: TrialLimits = TrialLimits(),
139
+ label: str = "trial",
140
+ ) -> TrialReport:
141
+ """Fork the parent at ``at_event`` and trial the candidate pack in
142
+ a fresh subprocess. CONTRACT v1.5 #1.
143
+
144
+ The fork is created HERE, in the parent process, with full
145
+ ``fork()`` semantics (lineage recorded, promote-block cut guard);
146
+ the child receives only the fork's run id. ``scenario`` is
147
+ ``"relative/path.py"`` or ``"relative/path.py::func"`` inside the
148
+ pack root — the function (default ``main``) is called with the
149
+ fork's ``Runtime`` and drives the trial; empty means just
150
+ ``run_until_idle()``. The scenario contract is
151
+ ``def main(rt): ...``.
152
+
153
+ Deterministic and key-free by default (``max_llm_calls=0``, empty
154
+ environment pass-through). Returns a :class:`TrialReport`; never
155
+ raises for in-trial failures — those are outcomes. Raises only
156
+ for parent-side setup problems (bad store, bad fork point), with
157
+ the same errors ``Runtime.load`` / ``fork()`` raise.
158
+ """
159
+ from activegraph.runtime.runtime import Runtime
160
+
161
+ parent_rt = Runtime.load(store_path, run_id=parent_run_id, behaviors=[])
162
+ fork_rt = parent_rt.fork(at_event=at_event, label=label, behaviors=[])
163
+ fork_run_id = fork_rt.run_id
164
+ initial_events = len(fork_rt.graph.events)
165
+ del fork_rt # the child owns the fork from here
166
+
167
+ job = {
168
+ "store_path": store_path,
169
+ "fork_run_id": fork_run_id,
170
+ "initial_events": initial_events,
171
+ "pack_root": str(Path(pack_source.root_dir).resolve()),
172
+ "expected_bundle_hash": pack_source.expected_bundle_hash,
173
+ "manifest_required": pack_source.manifest_required,
174
+ "scenario": scenario,
175
+ "limits": {
176
+ "max_rss_bytes": limits.max_rss_bytes,
177
+ "max_events": limits.max_events,
178
+ "max_llm_calls": limits.max_llm_calls,
179
+ "cpu_seconds": int(limits.wall_clock_seconds) + 5,
180
+ },
181
+ }
182
+ env = {
183
+ key: os.environ[key]
184
+ for key in ("PATH", "PYTHONPATH", "HOME", "LANG")
185
+ if key in os.environ
186
+ }
187
+ for key in limits.env_passthrough:
188
+ if key in os.environ:
189
+ env[key] = os.environ[key]
190
+
191
+ proc = subprocess.Popen(
192
+ [sys.executable, "-m", "activegraph.sandbox._child"],
193
+ stdin=subprocess.PIPE,
194
+ stdout=subprocess.PIPE,
195
+ stderr=subprocess.DEVNULL,
196
+ env=env,
197
+ text=True,
198
+ )
199
+ try:
200
+ stdout, _ = proc.communicate(
201
+ input=json.dumps(job), timeout=limits.wall_clock_seconds
202
+ )
203
+ exit_code: Optional[int] = proc.returncode
204
+ timed_out = False
205
+ except subprocess.TimeoutExpired:
206
+ proc.kill()
207
+ stdout, _ = proc.communicate()
208
+ exit_code = proc.returncode
209
+ timed_out = True
210
+
211
+ tail: dict[str, object] = {}
212
+ for line in reversed((stdout or "").strip().splitlines()):
213
+ try:
214
+ parsed = json.loads(line)
215
+ if isinstance(parsed, dict) and "outcome" in parsed:
216
+ tail = parsed
217
+ break
218
+ except json.JSONDecodeError:
219
+ continue
220
+
221
+ if timed_out:
222
+ outcome = "limits_exceeded"
223
+ detail = (
224
+ f"wall clock exceeded {limits.wall_clock_seconds}s; child killed"
225
+ )
226
+ elif tail:
227
+ outcome = str(tail.get("outcome", "crashed"))
228
+ if outcome not in TRIAL_OUTCOMES:
229
+ outcome = "crashed"
230
+ detail = str(tail.get("detail", ""))
231
+ else:
232
+ outcome = _EXIT_TO_OUTCOME.get(exit_code or -1, "crashed")
233
+ if outcome == "completed":
234
+ # Exit 0 with no tail is itself suspicious; say so.
235
+ outcome = "crashed"
236
+ detail = f"child exited {exit_code} with no report tail"
237
+
238
+ # The store is the record: re-read the fork run for the numbers.
239
+ events_appended = 0
240
+ behavior_failures = 0
241
+ try:
242
+ fork_view = Runtime.load(store_path, run_id=fork_run_id, behaviors=[])
243
+ events_appended = max(
244
+ 0, len(fork_view.graph.events) - initial_events
245
+ )
246
+ behavior_failures = len(fork_view.trace.failures())
247
+ except Exception as e: # noqa: BLE001 — report, never mask the trial
248
+ detail = f"{detail} (store re-read failed: {e})".strip()
249
+
250
+ return TrialReport(
251
+ outcome=outcome,
252
+ fork_run_id=fork_run_id,
253
+ events_appended=events_appended,
254
+ behavior_failures=behavior_failures,
255
+ detail=detail,
256
+ exit_code=exit_code,
257
+ )
@@ -0,0 +1,234 @@
1
+ """The trial child: fresh-interpreter half of the sandbox. v1.5 #1.
2
+
3
+ Reads one JSON job spec from stdin, applies rlimits, materializes the
4
+ candidate pack from artifacts (bundle-hash-pinned, manifest-verified),
5
+ loads the fork run, runs the scenario under budgets, and prints one
6
+ JSON report line to stdout. Everything richer than the report tail is
7
+ already in the store — the child's trial activity is ordinary events
8
+ in the fork's run.
9
+
10
+ Exit codes (mirrored in ``activegraph.sandbox._EXIT_TO_OUTCOME``):
11
+ 0 completed, 30 scenario_failed, 40 limits_exceeded,
12
+ 50 materialization_failed. Anything else reads as ``crashed``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import json
19
+ import sys
20
+ import traceback
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Optional
23
+
24
+
25
+ def _report(
26
+ outcome: str,
27
+ *,
28
+ fork_run_id: str,
29
+ events_appended: int,
30
+ behavior_failures: int,
31
+ detail: str,
32
+ exit_code: int,
33
+ ) -> None:
34
+ print(
35
+ json.dumps(
36
+ {
37
+ "outcome": outcome,
38
+ "fork_run_id": fork_run_id,
39
+ "events_appended": events_appended,
40
+ "behavior_failures": behavior_failures,
41
+ "detail": detail[:500],
42
+ }
43
+ ),
44
+ flush=True,
45
+ )
46
+ raise SystemExit(exit_code)
47
+
48
+
49
+ def _apply_rlimits(limits: dict[str, Any]) -> None:
50
+ """POSIX resource caps; on platforms without ``resource`` the
51
+ wall-clock and budget nets still hold (stated in the design)."""
52
+ try:
53
+ import resource
54
+ except ImportError: # pragma: no cover — Windows
55
+ return
56
+ max_rss = limits.get("max_rss_bytes")
57
+ if max_rss:
58
+ resource.setrlimit(resource.RLIMIT_AS, (max_rss, max_rss))
59
+ cpu = limits.get("cpu_seconds")
60
+ if cpu:
61
+ resource.setrlimit(resource.RLIMIT_CPU, (cpu, cpu))
62
+
63
+
64
+ def _materialize_pack(job: dict[str, Any]) -> Any:
65
+ """Verify pins, then import the candidate and return its Pack.
66
+
67
+ Order is the design's §3: bundle hash BEFORE any import (the pin
68
+ covers manifest.toml per the v1.4 amendment), then manifest schema,
69
+ then import, then the two-way surface check against the live Pack.
70
+ """
71
+ from activegraph.packs import Pack
72
+ from activegraph.packs.manifest import (
73
+ load_manifest,
74
+ verify_bundle_hash,
75
+ verify_surface,
76
+ )
77
+
78
+ root = Path(job["pack_root"])
79
+ manifest = None
80
+ if job.get("expected_bundle_hash"):
81
+ verify_bundle_hash(job["expected_bundle_hash"], root)
82
+ if job.get("manifest_required", True):
83
+ manifest = load_manifest(root)
84
+
85
+ spec = importlib.util.spec_from_file_location(
86
+ manifest.name if manifest else root.name,
87
+ root / "__init__.py",
88
+ submodule_search_locations=[str(root)],
89
+ )
90
+ if spec is None or spec.loader is None:
91
+ raise RuntimeError(f"cannot import pack module at {root}")
92
+ module = importlib.util.module_from_spec(spec)
93
+ sys.modules[spec.name] = module
94
+ spec.loader.exec_module(module)
95
+
96
+ packs = [v for v in vars(module).values() if isinstance(v, Pack)]
97
+ if manifest is not None:
98
+ packs = [p for p in packs if p.name == manifest.name]
99
+ if len(packs) != 1:
100
+ raise RuntimeError(
101
+ f"pack module must expose exactly one module-level Pack"
102
+ f"{' named ' + manifest.name if manifest else ''}; "
103
+ f"found {len(packs)}"
104
+ )
105
+ pack = packs[0]
106
+ if manifest is not None:
107
+ verify_surface(manifest, pack)
108
+ return pack
109
+
110
+
111
+ def _resolve_scenario(
112
+ root: Path, scenario: str
113
+ ) -> Optional[Callable[[Any], None]]:
114
+ if not scenario:
115
+ return None
116
+ path_part, _, func_name = scenario.partition("::")
117
+ func_name = func_name or "main"
118
+ scenario_path = (root / path_part).resolve()
119
+ spec = importlib.util.spec_from_file_location(
120
+ "_trial_scenario", scenario_path
121
+ )
122
+ if spec is None or spec.loader is None:
123
+ raise RuntimeError(f"cannot import scenario at {scenario_path}")
124
+ module = importlib.util.module_from_spec(spec)
125
+ spec.loader.exec_module(module)
126
+ fn = getattr(module, func_name, None)
127
+ if not callable(fn):
128
+ raise RuntimeError(
129
+ f"scenario {scenario!r} has no callable {func_name!r}"
130
+ )
131
+ return fn
132
+
133
+
134
+ def main() -> None:
135
+ job = json.loads(sys.stdin.read())
136
+ fork_run_id = str(job["fork_run_id"])
137
+ initial = int(job.get("initial_events", 0))
138
+ _apply_rlimits(job.get("limits", {}))
139
+
140
+ def counts(rt: Any) -> tuple[int, int]:
141
+ return (
142
+ max(0, len(rt.graph.events) - initial),
143
+ len(rt.trace.failures()),
144
+ )
145
+
146
+ try:
147
+ pack = _materialize_pack(job)
148
+ except Exception as e: # noqa: BLE001 — outcome, not crash
149
+ _report(
150
+ "materialization_failed",
151
+ fork_run_id=fork_run_id,
152
+ events_appended=0,
153
+ behavior_failures=0,
154
+ detail=f"{type(e).__name__}: {e}",
155
+ exit_code=50,
156
+ )
157
+
158
+ from activegraph.runtime.runtime import Runtime
159
+
160
+ budget: dict[str, Any] = {}
161
+ if job["limits"].get("max_events"):
162
+ budget["max_events"] = job["limits"]["max_events"]
163
+ # max_llm_calls=0 (the default) needs no budget dimension: the
164
+ # child configures NO llm_provider, so key-freedom is structural —
165
+ # an LLM behavior in the candidate fails loud at registration
166
+ # (MissingProviderError), it cannot silently reach a network. A
167
+ # positive cap is recorded for a future provider-wiring seam.
168
+ if job["limits"].get("max_llm_calls"):
169
+ budget["max_llm_calls"] = job["limits"]["max_llm_calls"]
170
+
171
+ rt = Runtime.load(
172
+ job["store_path"],
173
+ run_id=fork_run_id,
174
+ behaviors=[],
175
+ budget=budget or None,
176
+ )
177
+ rt.load_pack(pack)
178
+
179
+ try:
180
+ scenario_fn = _resolve_scenario(
181
+ Path(job["pack_root"]), job.get("scenario", "")
182
+ )
183
+ if scenario_fn is not None:
184
+ scenario_fn(rt)
185
+ else:
186
+ rt.run_until_idle()
187
+ except MemoryError:
188
+ appended, failures = counts(rt)
189
+ _report(
190
+ "limits_exceeded",
191
+ fork_run_id=fork_run_id,
192
+ events_appended=appended,
193
+ behavior_failures=failures,
194
+ detail="MemoryError under RLIMIT_AS",
195
+ exit_code=40,
196
+ )
197
+ except Exception as e: # noqa: BLE001 — outcome, not crash
198
+ appended, failures = counts(rt)
199
+ _report(
200
+ "scenario_failed",
201
+ fork_run_id=fork_run_id,
202
+ events_appended=appended,
203
+ behavior_failures=failures,
204
+ detail=f"{type(e).__name__}: {e}\n"
205
+ + traceback.format_exc(limit=3),
206
+ exit_code=30,
207
+ )
208
+
209
+ appended, failures = counts(rt)
210
+ exhausted = any(
211
+ e.type == "runtime.budget_exhausted"
212
+ for e in rt.graph.events[initial:]
213
+ )
214
+ if exhausted:
215
+ _report(
216
+ "limits_exceeded",
217
+ fork_run_id=fork_run_id,
218
+ events_appended=appended,
219
+ behavior_failures=failures,
220
+ detail="trial budget exhausted (see runtime.budget_exhausted)",
221
+ exit_code=40,
222
+ )
223
+ _report(
224
+ "completed",
225
+ fork_run_id=fork_run_id,
226
+ events_appended=appended,
227
+ behavior_failures=failures,
228
+ detail="",
229
+ exit_code=0,
230
+ )
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()