hecks 7.0.0 → 7.1.0

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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/postgres_era/lineage/chain_sql.rb +2 -1
  3. data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +4 -2
  4. data/lib/hecks/adapters/driven/postgres_era/lineage/split_sql.rb +16 -8
  5. data/lib/hecks/adapters/driven/postgres_era/lineage/transform_sql.rb +7 -3
  6. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +2 -1
  7. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/mint_request.rb +2 -1
  8. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/refusals.rb +2 -1
  9. data/lib/hecks/adapters/driving/cli_runner/bang_rule.rb +3 -0
  10. data/lib/hecks/adapters/driving/cli_runner/completion_words.rb +2 -0
  11. data/lib/hecks/adapters/driving/cli_runner/read_context.rb +6 -3
  12. data/lib/hecks/adapters/driving/cli_runner/renamed_names.rb +2 -1
  13. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +2 -2
  14. data/lib/hecks/adapters/driving/cli_runner/resume_words.rb +1 -0
  15. data/lib/hecks/adapters/driving/cli_runner/run_reference.rb +6 -3
  16. data/lib/hecks/adapters/driving/cli_runner/search_words.rb +2 -0
  17. data/lib/hecks/adapters/driving/cli_runner/tour_words.rb +1 -0
  18. data/lib/hecks/adapters/driving/cli_runner/verb_names.rb +2 -1
  19. data/lib/hecks/adapters/driving/cli_runner/verbosity.rb +2 -0
  20. data/lib/hecks/adapters/driving/cli_runner/word_help.rb +40 -0
  21. data/lib/hecks/adapters/driving/cli_runner.rb +2 -0
  22. data/lib/hecks/adapters/driving/mcp/progress_sink.rb +4 -2
  23. data/lib/hecks/adapters/driving/mcp.rb +2 -1
  24. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +2 -1
  25. data/lib/hecks/adapters/driving/ruby/aggregate_module/hooks.rb +28 -14
  26. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +4 -2
  27. data/lib/hecks/assembly.rb +18 -4
  28. data/lib/hecks/behaviors/expectations/adapter_selection.rb +82 -0
  29. data/lib/hecks/behaviors/expectations/boot.rb +12 -5
  30. data/lib/hecks/bluebook/acknowledgements.rb +1 -1
  31. data/lib/hecks/bluebook/command.rb +3 -2
  32. data/lib/hecks/bluebook/declaration.rb +2 -1
  33. data/lib/hecks/bluebook/dsl/adapter_builder.rb +9 -1
  34. data/lib/hecks/bluebook/dsl/bluebook_builder/metadata.rb +6 -4
  35. data/lib/hecks/bluebook/dsl/bluebook_builder/modules.rb +6 -3
  36. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/correlation_keys.rb +2 -1
  37. data/lib/hecks/bluebook/dsl/command_builder/folded_sets.rb +4 -2
  38. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +2 -1
  39. data/lib/hecks/bluebook/hecksagon.rb +6 -3
  40. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +9 -2
  41. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  42. data/lib/hecks/bluebook/model_check/aggregate_checks.rb +2 -1
  43. data/lib/hecks/bluebook/model_check/classification_checks.rb +51 -0
  44. data/lib/hecks/bluebook/model_check/context_checks.rb +2 -0
  45. data/lib/hecks/bluebook/model_check/redundant_relationships.rb +2 -1
  46. data/lib/hecks/ci_speed/adapters/shard_partitioner.adapter +7 -0
  47. data/lib/hecks/ci_speed/adapters/shard_partitioner.rb +66 -0
  48. data/lib/hecks/ci_speed/adapters/timings_source.adapter +7 -0
  49. data/lib/hecks/ci_speed/adapters/timings_source.rb +68 -0
  50. data/lib/hecks/ci_speed/ci_speed.bluebook +111 -0
  51. data/lib/hecks/ci_speed/ci_speed.ports.hecksagon +22 -0
  52. data/lib/hecks/ci_workflows/ci_workflows.bluebook +50 -6
  53. data/lib/hecks/coder/coder.bluebook +3 -1
  54. data/lib/hecks/command_names/index.rb +1 -1
  55. data/lib/hecks/command_names/resolution.rb +15 -5
  56. data/lib/hecks/composer/toolbox.rb +2 -1
  57. data/lib/hecks/corpus.rb +4 -0
  58. data/lib/hecks/doc/reference/readme.rb +1 -1
  59. data/lib/hecks/framework_bluebooks.rb +3 -2
  60. data/lib/hecks/fuzzing/differential.rb +1 -1
  61. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  62. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +1 -1
  63. data/lib/hecks/fuzzing/persistence_parity.rb +1 -1
  64. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  65. data/lib/hecks/hecks/adapters/ci_runs.adapter +7 -0
  66. data/lib/hecks/hecks/adapters/ci_runs.rb +80 -0
  67. data/lib/hecks/hecks/adapters/claude_agent_list.rb +43 -0
  68. data/lib/hecks/hecks/adapters/claude_agents.rb +50 -19
  69. data/lib/hecks/hecks/adapters/claude_jobs.rb +36 -0
  70. data/lib/hecks/hecks/adapters/codebase/corpus_queries.rb +2 -1
  71. data/lib/hecks/hecks/adapters/codebase/minitest_runs.rb +4 -2
  72. data/lib/hecks/hecks/adapters/codebase/run_options.rb +2 -1
  73. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +3 -19
  74. data/lib/hecks/hecks/adapters/dispatching/brief_text.rb +5 -3
  75. data/lib/hecks/hecks/adapters/dispatching/launcher.rb +2 -1
  76. data/lib/hecks/hecks/adapters/dispatching/plan.rb +8 -4
  77. data/lib/hecks/hecks/adapters/dispatching/settings.rb +2 -1
  78. data/lib/hecks/hecks/adapters/dispatching/settler.rb +4 -2
  79. data/lib/hecks/hecks/adapters/dispatching.rb +6 -3
  80. data/lib/hecks/hecks/adapters/doctor_reading.rb +4 -2
  81. data/lib/hecks/hecks/adapters/git_landing/carried.rb +42 -0
  82. data/lib/hecks/hecks/adapters/git_landing/finisher.rb +2 -1
  83. data/lib/hecks/hecks/adapters/git_landing/flagged.rb +32 -0
  84. data/lib/hecks/hecks/adapters/git_landing/tidy.rb +27 -14
  85. data/lib/hecks/hecks/adapters/git_landing.rb +30 -13
  86. data/lib/hecks/hecks/adapters/git_leftovers.adapter +7 -0
  87. data/lib/hecks/hecks/adapters/git_leftovers.rb +49 -0
  88. data/lib/hecks/hecks/adapters/git_main_pull.rb +2 -2
  89. data/lib/hecks/hecks/adapters/git_train.rb +9 -5
  90. data/lib/hecks/hecks/adapters/git_worktree_add.rb +8 -4
  91. data/lib/hecks/hecks/adapters/github_pull_requests.rb +2 -1
  92. data/lib/hecks/hecks/adapters/in_process_operations.rb +2 -1
  93. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +1 -1
  94. data/lib/hecks/hecks/adapters/journal_store/readings/composition.rb +2 -1
  95. data/lib/hecks/hecks/adapters/journal_store/translation_write/shape_edge.rb +4 -2
  96. data/lib/hecks/hecks/adapters/journal_store/translation_write.rb +4 -2
  97. data/lib/hecks/hecks/adapters/landed_branches.rb +38 -0
  98. data/lib/hecks/hecks/adapters/main_boarding.adapter +7 -0
  99. data/lib/hecks/hecks/adapters/main_boarding.rb +85 -0
  100. data/lib/hecks/hecks/adapters/main_integration/checks.rb +63 -0
  101. data/lib/hecks/hecks/adapters/main_integration/command_runner.rb +32 -0
  102. data/lib/hecks/hecks/adapters/main_integration/range_guard.rb +49 -0
  103. data/lib/hecks/hecks/adapters/main_integration/repository.rb +83 -0
  104. data/lib/hecks/hecks/adapters/main_integration/runs.rb +51 -0
  105. data/lib/hecks/hecks/adapters/main_integration/train.rb +74 -0
  106. data/lib/hecks/hecks/adapters/main_integration.adapter +7 -0
  107. data/lib/hecks/hecks/adapters/main_integration.rb +77 -0
  108. data/lib/hecks/hecks/adapters/main_verification.adapter +7 -0
  109. data/lib/hecks/hecks/adapters/main_verification.rb +89 -0
  110. data/lib/hecks/hecks/adapters/push_recorder.adapter +7 -0
  111. data/lib/hecks/hecks/adapters/push_recorder.rb +78 -0
  112. data/lib/hecks/hecks/adapters/red_flag_guard.rb +62 -0
  113. data/lib/hecks/hecks/adapters/red_flag_marker.rb +101 -0
  114. data/lib/hecks/hecks/adapters/red_flag_report.rb +59 -0
  115. data/lib/hecks/hecks/adapters/red_flag_survey.rb +65 -0
  116. data/lib/hecks/hecks/adapters/red_flagging.adapter +7 -0
  117. data/lib/hecks/hecks/adapters/red_flagging.rb +98 -0
  118. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +6 -1
  119. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +6 -3
  120. data/lib/hecks/hecks/adapters/settings_reading/event_tap_row.rb +33 -0
  121. data/lib/hecks/hecks/adapters/settings_reading.rb +3 -1
  122. data/lib/hecks/hecks/codebase.behaviors +0 -6
  123. data/lib/hecks/hecks/custodian.behaviors +22 -1
  124. data/lib/hecks/hecks/delegate/delegate.bluebook +128 -15
  125. data/lib/hecks/hecks/deliver/deliver.bluebook +450 -2
  126. data/lib/hecks/hecks/environments/memory.world +5 -0
  127. data/lib/hecks/hecks/hecks.contextmap +8 -0
  128. data/lib/hecks/hecks/hecks.hecksagon +106 -6
  129. data/lib/hecks/hecks/hecks.world +9 -1
  130. data/lib/hecks/hecks/migrate/migrate.bluebook +6 -1
  131. data/lib/hecks/hecks/redflag/redflag.bluebook +249 -0
  132. data/lib/hecks/hecks/test/test.bluebook +0 -19
  133. data/lib/hecks/language/adapter.bluebook +13 -0
  134. data/lib/hecks/language/bluebook/vocabulary.bluebook +8 -9
  135. data/lib/hecks/language/sentence/sentence.bluebook +2 -0
  136. data/lib/hecks/pipeline/pipeline.bluebook +251 -0
  137. data/lib/hecks/pipeline/pipeline.hecksagon +17 -0
  138. data/lib/hecks/pipeline_board/adapters/cache.rb +43 -0
  139. data/lib/hecks/pipeline_board/adapters/pipeline_fill.rb +56 -0
  140. data/lib/hecks/pipeline_board/adapters/pipeline_git.rb +82 -0
  141. data/lib/hecks/pipeline_board/adapters/pipeline_github/runs.rb +66 -0
  142. data/lib/hecks/pipeline_board/adapters/pipeline_github.adapter +7 -0
  143. data/lib/hecks/pipeline_board/adapters/pipeline_github.rb +70 -0
  144. data/lib/hecks/pipeline_board/adapters/pipeline_jobs.adapter +7 -0
  145. data/lib/hecks/pipeline_board/adapters/pipeline_jobs.rb +87 -0
  146. data/lib/hecks/pipeline_board/adapters/pipeline_journal/chain.rb +40 -0
  147. data/lib/hecks/pipeline_board/adapters/pipeline_journal/tap.rb +75 -0
  148. data/lib/hecks/pipeline_board/adapters/pipeline_journal.adapter +7 -0
  149. data/lib/hecks/pipeline_board/adapters/pipeline_journal.rb +83 -0
  150. data/lib/hecks/pipeline_board/adapters/pipeline_stages/ci_tiles.rb +85 -0
  151. data/lib/hecks/pipeline_board/adapters/pipeline_stages/fanout.rb +73 -0
  152. data/lib/hecks/pipeline_board/adapters/pipeline_stages/gather.rb +57 -0
  153. data/lib/hecks/pipeline_board/adapters/pipeline_stages/metrics.rb +36 -0
  154. data/lib/hecks/pipeline_board/adapters/pipeline_stages/strip.rb +102 -0
  155. data/lib/hecks/pipeline_board/adapters/pipeline_stages/tile_kit.rb +57 -0
  156. data/lib/hecks/pipeline_board/adapters/pipeline_stages/work_tiles.rb +59 -0
  157. data/lib/hecks/pipeline_board/adapters/pipeline_stages.adapter +7 -0
  158. data/lib/hecks/pipeline_board/adapters/pipeline_stages.rb +84 -0
  159. data/lib/hecks/pipeline_board/adapters/pipeline_tickets.adapter +7 -0
  160. data/lib/hecks/pipeline_board/adapters/pipeline_tickets.rb +77 -0
  161. data/lib/hecks/pipeline_board/adapters/pipeline_web/page.html +292 -0
  162. data/lib/hecks/pipeline_board/adapters/pipeline_web/sequence.html +190 -0
  163. data/lib/hecks/pipeline_board/adapters/pipeline_web/server.rb +105 -0
  164. data/lib/hecks/pipeline_board/adapters/pipeline_web/snapshot.rb +57 -0
  165. data/lib/hecks/pipeline_board/adapters/pipeline_web/stream.rb +59 -0
  166. data/lib/hecks/pipeline_board/adapters/pipeline_web/wiring.html +536 -0
  167. data/lib/hecks/pipeline_board/adapters/pipeline_web.adapter +7 -0
  168. data/lib/hecks/pipeline_board/adapters/pipeline_web.rb +80 -0
  169. data/lib/hecks/pipeline_board/adapters/pipeline_wiring/details.rb +85 -0
  170. data/lib/hecks/pipeline_board/adapters/pipeline_wiring/graph.rb +120 -0
  171. data/lib/hecks/pipeline_board/adapters/pipeline_wiring.adapter +7 -0
  172. data/lib/hecks/pipeline_board/adapters/pipeline_wiring.rb +36 -0
  173. data/lib/hecks/pipeline_board/adapters/probe.rb +48 -0
  174. data/lib/hecks/pipeline_board/pipeline_board.bluebook +144 -0
  175. data/lib/hecks/pipeline_board/pipeline_board.hecksagon +12 -0
  176. data/lib/hecks/pipeline_board/pipeline_board.ports.hecksagon +47 -0
  177. data/lib/hecks/ports/persistence/binding_policy.rb +2 -1
  178. data/lib/hecks/ports/persistence/plugins/era/translation/compose.rb +4 -3
  179. data/lib/hecks/ports/persistence/repository_factory.rb +2 -1
  180. data/lib/hecks/projections/ci_pipeline/table.rb +2 -1
  181. data/lib/hecks/projections/deploy/lambda/stack/recipes.rb +2 -2
  182. data/lib/hecks/projections/undo.rb +2 -1
  183. data/lib/hecks/projector/cli_projector/command_help.rb +5 -4
  184. data/lib/hecks/projector/cli_projector/renames.rb +4 -2
  185. data/lib/hecks/projector/cli_projector/usage.rb +4 -3
  186. data/lib/hecks/projector/exporter/adapters.rb +3 -2
  187. data/lib/hecks/projector/narrate_projector/use_cases.rb +74 -0
  188. data/lib/hecks/projector/narrate_projector.rb +10 -3
  189. data/lib/hecks/quality_control/adapters/main_maintenance.rb +9 -5
  190. data/lib/hecks/quality_control/adapters/qa_auto_mint.rb +21 -2
  191. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +1 -1
  192. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +2 -1
  193. data/lib/hecks/quality_control/cli/qa_fix_main_bug/publishing.rb +8 -4
  194. data/lib/hecks/quality_control/cli/qa_fix_main_bug.rb +10 -8
  195. data/lib/hecks/quality_control/cli/qa_generated_domains/promotion.rb +2 -2
  196. data/lib/hecks/quality_control/cli/qa_main_gate/gate_run.rb +6 -3
  197. data/lib/hecks/quality_control/cli/qa_main_gate/logging.rb +8 -4
  198. data/lib/hecks/quality_control/cli/qa_main_gate/shell.rb +4 -2
  199. data/lib/hecks/quality_control/cli/qa_main_gate/sweeping.rb +8 -4
  200. data/lib/hecks/quality_control/cli/qa_main_gate/worktree.rb +3 -2
  201. data/lib/hecks/quality_control/cli/qa_main_gate.rb +10 -5
  202. data/lib/hecks/quality_control/cli/qa_sweep/all_mode/children.rb +1 -1
  203. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +1 -1
  204. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  205. data/lib/hecks/quality_control/cli/qa_tick.rb +6 -3
  206. data/lib/hecks/quality_control/quality_control.bluebook +11 -11
  207. data/lib/hecks/quality_control/quality_control.ports.hecksagon +6 -3
  208. data/lib/hecks/query_ir/touchpoints.rb +2 -1
  209. data/lib/hecks/refused.rb +2 -1
  210. data/lib/hecks/release/approval.rb +4 -2
  211. data/lib/hecks/runtime/event_log.rb +27 -0
  212. data/lib/hecks/runtime/event_tap.rb +106 -0
  213. data/lib/hecks/runtime/loader/boot_steps.rb +1 -1
  214. data/lib/hecks/runtime/loader.rb +35 -19
  215. data/lib/hecks/runtime/registry/durability_warnings.rb +12 -5
  216. data/lib/hecks/runtime/registry/runtime_state.rb +4 -1
  217. data/lib/hecks/runtime/registry/saga_anchor.rb +54 -0
  218. data/lib/hecks/runtime/registry/saga_durability_rule.rb +66 -0
  219. data/lib/hecks/runtime/registry/saga_persistence.rb +58 -12
  220. data/lib/hecks/runtime/registry/verification.rb +7 -3
  221. data/lib/hecks/runtime/saga_interpreter/lifecycle.rb +2 -1
  222. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  223. data/lib/hecks/rust_format.rb +2 -1
  224. data/lib/hecks/shape_ledger/edge_hygiene.rb +10 -5
  225. data/lib/hecks/shape_ledger.rb +1 -1
  226. data/lib/hecks/storehouse/audit.rb +2 -1
  227. data/lib/hecks/storehouse/identity.rb +6 -3
  228. data/lib/hecks/storehouse.rb +2 -1
  229. data/lib/hecks/tickets/bluebook/tickets.bluebook +5 -4
  230. data/lib/hecks/tools/ci_workflows.rb +2 -1
  231. data/lib/hecks/tools/document.rb +12 -1
  232. data/lib/hecks/tools/downstream_model_check.rb +4 -2
  233. data/lib/hecks/tools/fuzz_sweep/shards.rb +9 -30
  234. data/lib/hecks/tools/model_check/examination.rb +25 -2
  235. data/lib/hecks/tools/move_disables.rb +4 -2
  236. data/lib/hecks/tools/regeneration_run/scratch.rb +2 -1
  237. data/lib/hecks/tools/rspec_shard_files.rb +12 -15
  238. data/lib/hecks/tools/sequence_script.rb +1 -1
  239. data/lib/hecks/tools/shard_planner.rb +65 -0
  240. data/lib/hecks/tools/stage_progress.rb +2 -1
  241. data/lib/hecks/tools/stress_concurrency_specs.rb +2 -1
  242. data/lib/hecks/version.rb +1 -1
  243. data/lib/hecks/vocabulary.rb +8 -9
  244. data/lib/hecks.rb +5 -2
  245. data/prebuilt/{13c04bdc5970295b310f0a980d397ba722232c8da99cc1a0cdb383249921e871.marshal → 4b81646f769ae5b1e7457f9b11c48d8bc1093e96a1dd6fb61864065121ca6f01.marshal} +0 -0
  246. data/prebuilt/{e3857b77f47d9ecc1787b2e1f5136364f3cb3444ef4aea5479f3b98167affea2.marshal → 681890632e3f4a99646623dcae44af36b1bad66c515f5abf67a2460372b9c6fd.marshal} +0 -0
  247. data/rust/host/HECKS_RELEASE +1 -1
  248. data/rust/parser/src/emit.rs +5 -1
  249. data/rust/parser/src/ir.rs +2 -0
  250. data/rust/parser/src/keywords.rs +12 -0
  251. data/rust/parser/src/main.rs +1 -0
  252. data/rust/parser/src/parse/adapter.rs +1 -0
  253. data/rust/parser/src/phrases.rs +16 -0
  254. data/rust/parser/src/sentence/examples.rs +93 -0
  255. data/rust/parser/src/sentence/mod.rs +1 -0
  256. metadata +89 -5
  257. data/lib/hecks/tools/refresh_rspec_runtime_baseline.rb +0 -166
@@ -58,8 +58,10 @@ module Hecks
58
58
 
59
59
  # Reads the decision's inputs from the environment `release.yml` gives it.
60
60
  #
61
- # @param env [#[]] `COMMIT_MESSAGE`, or `COMMIT` (the first-parent commit that set the version,
62
- # whose message is read as {VersionCommit} reads it), `TAG_EXISTS` (true/false), `APPROVED_BY`
61
+ # @param env [#[]] `COMMIT_MESSAGE`, or `COMMIT` (the first-parent commit that set the
62
+ # version,
63
+ # whose message is read as {VersionCommit} reads it), `TAG_EXISTS` (true/false),
64
+ # `APPROVED_BY`
63
65
  # @param out [#puts] where the words go
64
66
  # @return [Integer] the exit status: 0 to go ahead, 1 to refuse
65
67
  def check(env: ENV, out: $stdout)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Runtime
5
+ # The registry's list of emitted events. It is an Array in every respect; the addition is two
6
+ # observers, told of each event as it is appended and of each policy reaction as it is logged,
7
+ # which is how an opt-in tap sees what the runtime dispatched.
8
+ class EventLog < Array
9
+ # @return [#call, nil] told of each event appended; nil when nothing watches
10
+ attr_accessor :observer
11
+
12
+ # @return [#call, nil] told of each policy reaction with its record and the identity of the
13
+ # event it answered; nil when nothing watches
14
+ attr_accessor :reaction_observer
15
+
16
+ # Appends an event and tells the observer of it.
17
+ #
18
+ # @param event [Runtime::Event] the emitted event
19
+ # @return [EventLog] self
20
+ def <<(event)
21
+ super
22
+ observer&.call(event)
23
+ self
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "event"
7
+
8
+ module Hecks
9
+ module Runtime
10
+ # An opt-in, append-only local log of what the runtime dispatched: one JSON line per event and
11
+ # one per policy delivery. It exists for the domains that persist nothing (Memory keeps no
12
+ # journal), so a reader such as the PipelineBoard can still see them.
13
+ #
14
+ # It is off unless a world declares it: `event_log "EventTap", path: "...", max_bytes: N`. A
15
+ # client's world declares nothing, so a client writes no file. The boot may hand in an
16
+ # override for a process no hecks command runs (a spec run, and the children it starts): a
17
+ # path replaces the world's, and an empty value switches the tap off, so a spec never writes
18
+ # the owner's log. The log holds names, identities and outcomes, never payloads. A write
19
+ # that fails is dropped: logging never refuses a command.
20
+ class EventTap
21
+ # The world verb that switches the tap on.
22
+ VERB = "event_log"
23
+
24
+ # Bytes after which the log moves to `<path>.1` and starts again.
25
+ DEFAULT_MAX_BYTES = 5_000_000
26
+
27
+ # Switches the tap on for a registry whose Hecks world declares it.
28
+ #
29
+ # @param registry [Runtime::Registry] the registry whose events and reactions are tapped
30
+ # @param override [String, nil] a path in place of the world's; empty switches the tap off
31
+ # @return [EventTap, nil] the tap, nil when no world asks for one
32
+ def self.install(registry, override: nil)
33
+ settings = registry.world("Hecks")&.for_verb(VERB).to_h
34
+ path = path_for(settings, override)
35
+ return nil unless path
36
+
37
+ tap = new(path: path, max_bytes: settings.fetch(:max_bytes, DEFAULT_MAX_BYTES))
38
+ registry.event_log.observer = tap.method(:event)
39
+ registry.event_log.reaction_observer = tap.method(:reaction)
40
+ tap
41
+ end
42
+
43
+ # Where a world's tap writes: the override when one is given, else the world's path.
44
+ #
45
+ # @param settings [Hash] the Hecks world's `event_log` settings
46
+ # @param override [String, nil] a path in place of the world's; empty switches the tap off
47
+ # @return [String, nil] the log file; nil for no declared tap or an empty override
48
+ def self.path_for(settings, override = nil)
49
+ return nil if settings.to_h[:path].to_s.empty?
50
+
51
+ path = override.nil? ? settings[:path] : override
52
+ path.to_s.empty? ? nil : path
53
+ end
54
+
55
+ # @param path [String] the log file; `~` is the home directory
56
+ # @param max_bytes [Integer] the size at which the log rotates
57
+ def initialize(path:, max_bytes: DEFAULT_MAX_BYTES)
58
+ @path = File.expand_path(path.to_s)
59
+ @max_bytes = max_bytes.to_i
60
+ @lock = Mutex.new
61
+ end
62
+
63
+ # Appends one line for an emitted event.
64
+ #
65
+ # @param event [Runtime::Event] the event
66
+ # @return [void]
67
+ def event(event)
68
+ write("kind" => "event", "at" => event.occurred_at, "aggregate" => event.aggregate,
69
+ "id" => event.id.to_s, "event" => event.name)
70
+ end
71
+
72
+ # Appends one line for a policy delivery: which policy reacted to which event, the command it
73
+ # reached and what became of the delivery.
74
+ #
75
+ # @param record [Hash{Symbol => Object}] the reaction's outcome
76
+ # @param identity [String, Integer, nil] the identity of the event instance it answered
77
+ # @return [void]
78
+ def reaction(record, identity)
79
+ write("kind" => "reaction", "at" => Event.stamp, "id" => identity.to_s, "event" => record[:on].to_s,
80
+ "policy" => record[:policy].to_s, "command" => record[:trigger].to_s, "outcome" => outcome(record))
81
+ end
82
+
83
+ private
84
+
85
+ def outcome(record)
86
+ return "delivered" if record[:delivered]
87
+
88
+ "refused: #{record[:reason].to_s.slice(0, 160)}"
89
+ end
90
+
91
+ def write(line)
92
+ @lock.synchronize do
93
+ FileUtils.mkdir_p(File.dirname(@path))
94
+ rotate
95
+ File.open(@path, "a") { |file| file.puts(JSON.generate(line)) }
96
+ end
97
+ rescue SystemCallError, IOError
98
+ nil
99
+ end
100
+
101
+ def rotate
102
+ File.rename(@path, "#{@path}.1") if File.exist?(@path) && File.size(@path) >= @max_bytes
103
+ end
104
+ end
105
+ end
106
+ end
@@ -49,7 +49,7 @@ module Hecks
49
49
  registry.verify!
50
50
 
51
51
  gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
52
- registry.saga_domains.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
52
+ registry.saga_domains.any? { |domain| registry.saga_durable?(domain) }
53
53
  gates.run!(:post_verify, registry, directory)
54
54
  gates
55
55
  end
@@ -2,6 +2,7 @@ require_relative "../ports/loading"
2
2
  require_relative "../ports/persistence"
3
3
  require_relative "../ports/dispatch"
4
4
  require_relative "dispatcher"
5
+ require_relative "event_tap"
5
6
  require_relative "boot_gates"
6
7
  require_relative "registry"
7
8
  require_relative "loader/boot_steps"
@@ -23,23 +24,26 @@ module Hecks
23
24
  # @param path [String] a domain directory to boot
24
25
  # @param shared [String, nil] shared ports/adapters root; nil walks up from `path`
25
26
  # @param environment [String, nil] environment overlay loaded after the domain; nil loads none
27
+ # @param event_tap [String, nil] a path in place of the Hecks world's event tap; empty
28
+ # switches the tap off, nil leaves the world's where it is
26
29
  # @return [Runtime::Dispatcher, Adapters::Lambda::RemoteDispatcher] the booted dispatcher
27
30
  # @raise [Errno::ENOENT] if `path` names no domain directory
28
31
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
29
- def self.boot(path, shared: nil, environment: nil)
30
- boot_described(describe(path, shared: shared, environment: environment))
32
+ def self.boot(path, shared: nil, environment: nil, event_tap: nil)
33
+ boot_described(describe(path, shared: shared, environment: environment), event_tap: event_tap)
31
34
  end
32
35
 
33
36
  # Finishes a boot from declarations `describe` already loaded: runs every boot gate and builds
34
37
  # the dispatcher, without reading the domain's files again.
35
38
  #
36
39
  # @param described [Described] what `describe` answered for the domain to boot
40
+ # @param event_tap [String, nil] a path in place of the Hecks world's event tap; see `boot`
37
41
  # @return [Runtime::Dispatcher, Adapters::Lambda::RemoteDispatcher] the booted dispatcher
38
42
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
39
- def self.boot_described(described)
43
+ def self.boot_described(described, event_tap: nil)
40
44
  registry = described.registry
41
45
  run_boot_gates!(registry, described.directory)
42
- dispatcher = dispatcher_for(registry)
46
+ dispatcher = dispatcher_for(registry, event_tap)
43
47
  redrive_outbox!(dispatcher)
44
48
  redrive_sagas!(dispatcher)
45
49
  seed_privacy_markings!(dispatcher, registry)
@@ -95,39 +99,41 @@ module Hecks
95
99
  dispatcher.redrive_sagas!
96
100
  end
97
101
 
98
- # Boots the exact bluebook/hecksagon/world files in `paths`, in place —
99
- # unlike `boot`, no directory globbing and no copying into a temp dir
100
- # (a `persisted_by` path must resolve against the real project root,
101
- # not a copy's).
102
+ # Boots the exact bluebook/hecksagon/world files in `paths`, in place: unlike `boot`, no
103
+ # directory globbing and no copying into a temp dir (a `persisted_by` path must resolve
104
+ # against the real project root, not a copy's).
102
105
  #
103
106
  # @param paths [Array<String>, String] the exact file paths to boot, in load order
104
107
  # @param shared [String, nil] shared ports/adapters root; nil walks up from the first path
105
- # @param environment [String, nil] environment overlay loaded after the named files; nil
106
- # loads none
108
+ # @param environment [String, nil] overlay loaded after the named files; nil loads none
109
+ # @param adapters [Array<String>, nil] exactly the `.adapter` files to load, the project's own
110
+ # left unloaded even when this is empty; nil leaves the choice to `load_files`
111
+ # @param event_tap [String, nil] a path in place of the Hecks world's event tap; see `boot`
107
112
  # @return [Runtime::Dispatcher, Adapters::Lambda::RemoteDispatcher] the booted dispatcher
108
113
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
109
- def self.boot_files(paths, shared: nil, environment: nil)
114
+ def self.boot_files(paths, shared: nil, environment: nil, adapters: nil, event_tap: nil)
110
115
  loading = Ports::Loading.bootstrap
111
116
  files = Array(paths).map { |path| File.expand_path(path) }
112
117
  directory = File.dirname(files.first)
113
- registry = load_files(loading, files, shared, environment)
118
+ registry = load_files(loading, files + Array(adapters), shared, environment, chosen: !adapters.nil?)
114
119
 
115
120
  run_boot_gates!(registry, directory)
116
- dispatcher_for(registry)
121
+ dispatcher_for(registry, event_tap)
117
122
  end
118
123
 
119
124
  # Loads exactly `files` into a fresh Registry rooted beside their directory, answering it. An
120
125
  # `.adapter` among them stands in for the shared root's adapters, which are then left
121
- # unloaded, so a
122
- # test can answer a port with its own adapter and never construct the domain's real one.
123
- def self.load_files(loading, files, shared, overlay)
126
+ # unloaded, so a test can answer a port with its own adapter and never construct the domain's
127
+ # real one. `chosen` says the caller chose the adapters itself (a behaviors suite, ADR 0171),
128
+ # so the shared root's stay unloaded even when it chose none.
129
+ def self.load_files(loading, files, shared, overlay, chosen: false)
124
130
  directory = File.dirname(files.first)
125
131
  root = loading.shared_root(shared, directory)
126
132
  registry = Registry.new(root: File.dirname(directory))
127
133
 
128
134
  Hecks.with_registry(registry) do
129
135
  loading.load_library
130
- loading.load_project(root, adapters: files.none? { |file| file.end_with?(".adapter") })
136
+ loading.load_project(root, adapters: !chosen && files.none? { |file| file.end_with?(".adapter") })
131
137
  loading.load_selected(files, environment: overlay)
132
138
  end
133
139
  registry
@@ -139,15 +145,25 @@ module Hecks
139
145
  # means a deploy target exists.
140
146
  #
141
147
  # @param registry [Runtime::Registry] the booted registry
148
+ # @param event_tap [String, nil] a path in place of the Hecks world's event tap; see `boot`
142
149
  # @return [Runtime::Dispatcher, Object] the dispatcher the boot hands back
143
150
  # @raise [Runtime::WiringError] if the bound adapter cannot build a dispatcher
144
- def self.dispatcher_for(registry)
151
+ def self.dispatcher_for(registry, event_tap = nil)
145
152
  settings = registry.world(registry.bluebooks.keys.first)&.for_verb(Ports::Dispatch::VERB) || {}
146
- return Dispatcher.new(registry) unless settings[:adapter]
153
+ return local_dispatcher(registry, event_tap) unless settings[:adapter]
147
154
 
148
155
  Ports::Dispatch.dispatcher(registry, settings)
149
156
  end
150
157
 
158
+ # The local `Dispatcher`, with the opt-in event log tapped when the Hecks world asks for it.
159
+ #
160
+ # @param registry [Runtime::Registry] the booted registry
161
+ # @param event_tap [String, nil] a path in place of the Hecks world's event tap; see `boot`
162
+ # @return [Runtime::Dispatcher]
163
+ def self.local_dispatcher(registry, event_tap = nil)
164
+ Dispatcher.new(registry).tap { EventTap.install(registry, override: event_tap) }
165
+ end
166
+
151
167
  # Installs the Ruby facade over a dispatcher built by hand. See `Hecks.bind_runtime`.
152
168
  #
153
169
  # @param dispatcher [Runtime::Dispatcher] a dispatcher over a verified registry
@@ -69,18 +69,25 @@ module Hecks
69
69
  # A warning, not a refusal: running sagas on a store with no `save_saga` is
70
70
  # legitimate on purpose in a fast in-memory test/dev boot.
71
71
  def warn_undurable_sagas!(hecksagon)
72
- bluebook_ir = bluebook(hecksagon.domain)
73
- return unless bluebook_ir
74
- return if bluebook_ir.process_managers.empty?
75
- return unless saga_persistence(hecksagon.domain).equal?(Ports::Persistence::NULL_SAGA_STORE)
72
+ undurable = undurable_process_managers(hecksagon.domain)
73
+ return if undurable.empty?
76
74
 
77
- names = bluebook_ir.process_managers.map(&:name).join(", ")
75
+ names = undurable.map(&:name).join(", ")
78
76
  warn "[hecks] #{hecksagon.domain} declares process_manager(s) #{names} but its resolved " \
79
77
  "persistence adapter has no save_saga — saga state advances correctly in-process " \
80
78
  "and is LOST on restart (no checkpoint, no rehydration, no compensation replay). " \
81
79
  "Bind this domain to an adapter that implements save_saga if this process_manager " \
82
80
  "must survive a crash."
83
81
  end
82
+
83
+ # The process managers whose checkpoints no store keeps: each is held by its starting
84
+ # aggregate's store or by the domain's, and either may be the null store.
85
+ def undurable_process_managers(domain)
86
+ process_managers = bluebook(domain)&.process_managers || []
87
+ process_managers.select do |process_manager|
88
+ saga_persistence(domain, process_manager).equal?(Ports::Persistence::NULL_SAGA_STORE)
89
+ end
90
+ end
84
91
  end
85
92
  end
86
93
  end
@@ -1,3 +1,5 @@
1
+ require_relative "../event_log"
2
+
1
3
  module Hecks
2
4
  module Runtime
3
5
  class Registry
@@ -13,7 +15,7 @@ module Hecks
13
15
  :saga_dispatch_log, :policy_dispatch_log, :cross_domain_log, :saga_instances) do
14
16
  # @return [RuntimeState] every log empty and no saga instances
15
17
  def self.fresh
16
- new([], [], {}.compare_by_identity, [], [], [], [], Hash.new { |hash, key| hash[key] = {} })
18
+ new(EventLog.new, [], {}.compare_by_identity, [], [], [], [], Hash.new { |hash, key| hash[key] = {} })
17
19
  end
18
20
 
19
21
  # Empties every log and drops every saga instance.
@@ -31,6 +33,7 @@ module Hecks
31
33
  def log_reaction(record, event)
32
34
  reaction_log << record
33
35
  reaction_events[record] = event if event
36
+ event_log.reaction_observer&.call(record, event)
34
37
  end
35
38
  end
36
39
  end
@@ -0,0 +1,54 @@
1
+ module Hecks
2
+ module Runtime
3
+ class Registry
4
+ # Which aggregate's store holds a process manager's checkpoints: the one aggregate whose
5
+ # command emits the event the process manager starts on, when the domain's hecksagon binds
6
+ # that aggregate on its own. A process manager with no such aggregate is held by the
7
+ # domain's store. Mixed into {SagaPersistence}.
8
+ module SagaAnchor
9
+ # @param domain [String, Symbol] the domain declaring the process manager
10
+ # @param process_manager [Bluebook::ProcessManager, String] the process manager or its name
11
+ # @return [Bluebook::Aggregate, nil] the starting aggregate with a bind of its own, or nil
12
+ def saga_anchor(domain, process_manager)
13
+ declared = declared_process_manager(domain, process_manager) or return nil
14
+ starters = starting_aggregates(domain, declared.starts_on.to_s)
15
+ return nil unless starters.size == 1 && bound_on_its_own?(domain, starters.first)
16
+
17
+ starters.first
18
+ end
19
+
20
+ private
21
+
22
+ # Every process manager of the domain whose store is not the domain's own, by name.
23
+ def routed_sagas(domain)
24
+ home = saga_persistence(domain)
25
+ stores = process_managers_of(domain).to_h { |declared| [declared.name, saga_persistence(domain, declared)] }
26
+ stores.reject { |_name, store| store.equal?(home) }
27
+ end
28
+
29
+ def process_managers_of(domain) = bluebook(domain)&.process_managers || []
30
+
31
+ def declared_process_manager(domain, process_manager)
32
+ return process_manager if process_manager.respond_to?(:starts_on)
33
+
34
+ process_managers_of(domain).find { |declared| declared.name == process_manager.to_s }
35
+ end
36
+
37
+ def starting_aggregates(domain, event_name)
38
+ return [] if event_name.empty?
39
+
40
+ bluebook(domain).aggregates.select do |aggregate|
41
+ aggregate.commands.any? { |command| command.emits.map(&:to_s).include?(event_name) }
42
+ end
43
+ end
44
+
45
+ def bound_on_its_own?(domain, aggregate)
46
+ binds = hecksagon(domain)&.binds || []
47
+ binds.any? do |bind|
48
+ bind.aggregate_name.to_s == aggregate.hecks_name.to_s && bind.verb.to_s == Ports::Persistence::VERB
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,66 @@
1
+ module Hecks
2
+ module Runtime
3
+ class Registry
4
+ # The refusal that keeps a saga as durable as the aggregates it moves (ADR 0170): a process
5
+ # manager whose checkpoints no store keeps may not dispatch to an aggregate a store keeps
6
+ # across a restart. All-Memory stays allowed and keeps its warning. Mixed into {Verification}.
7
+ module SagaDurabilityRule
8
+ private
9
+
10
+ # Reads every declared bluebook, not each hecksagon: a domain kept by its world's
11
+ # `default_adapter` or by an attaching chapter has process managers too.
12
+ def refuse_forgetful_sagas!
13
+ @declared.bluebooks.each_key do |domain|
14
+ process_managers_of(domain).each { |process_manager| refuse_forgetful_saga!(domain, process_manager) }
15
+ end
16
+ end
17
+
18
+ def refuse_forgetful_saga!(domain, process_manager)
19
+ return unless saga_persistence(domain, process_manager).equal?(Ports::Persistence::NULL_SAGA_STORE)
20
+
21
+ kept = dispatched_aggregates(domain, process_manager).find { |aggregate| kept_across_restart(domain, aggregate) }
22
+ return unless kept
23
+
24
+ raise WiringError, forgetful_saga_wording(domain, process_manager, kept)
25
+ end
26
+
27
+ # The domain's own aggregates a process manager's legs and compensations dispatch to.
28
+ def dispatched_aggregates(domain, process_manager)
29
+ names = dispatch_specs(process_manager).map { |spec| aggregate_of_command(spec.command_name) }
30
+ bluebook(domain).aggregates.select { |aggregate| names.include?(aggregate.name.to_s) }
31
+ end
32
+
33
+ def dispatch_specs(process_manager)
34
+ process_manager.handlers.flat_map(&:dispatches).flat_map { |spec| [spec, spec.compensates].compact }
35
+ end
36
+
37
+ # `"Drawer.Take"` or `"Wire::Drawer.Take"` names the aggregate `Drawer`.
38
+ def aggregate_of_command(command_name)
39
+ path = command_name.to_s.split(".", 2).first
40
+ path.to_s.split("::").last
41
+ end
42
+
43
+ # The adapter that keeps the aggregate across a restart, or nil. Memory keeps nothing, and
44
+ # a RemoteRuntime's far side keeps the saga as well as the record. An aggregate whose bind
45
+ # cannot be resolved is left to the refusal that resolution raises at dispatch.
46
+ def kept_across_restart(domain, aggregate)
47
+ bind = Ports::Persistence::BindingPolicy.resolve(self, domain, aggregate)
48
+ return nil if bind.adapter == Ports::Persistence::DEFAULT_ADAPTER
49
+ return nil if adapter_class(bind.adapter) <= Ports::Persistence::RemoteRuntime
50
+
51
+ bind.adapter
52
+ rescue WiringError
53
+ nil
54
+ end
55
+
56
+ def forgetful_saga_wording(domain, process_manager, aggregate)
57
+ "#{domain}'s process_manager #{process_manager.name} dispatches to #{aggregate.name}, which " \
58
+ "#{kept_across_restart(domain, aggregate)} keeps across a restart, but its own checkpoints are kept " \
59
+ "nowhere — after a restart the records stay moved and the saga that would finish or undo the move is " \
60
+ "gone. A forgotten decision: persist the aggregate it starts from (or the domain's first aggregate) " \
61
+ "with an adapter that keeps sagas, or keep the whole domain in Memory on purpose (ADR 0170)."
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,37 +1,70 @@
1
1
  require_relative "../saga_pending_dispatch"
2
+ require_relative "saga_anchor"
2
3
 
3
4
  module Hecks
4
5
  module Runtime
5
6
  class Registry
6
7
  # A domain's sagas persist through whatever adapter its aggregates already use;
7
8
  # `save_saga`/`delete_saga`/`each_saga` are optional, `respond_to?`-checked capabilities.
9
+ # A process manager started by an aggregate the hecksagon binds on its own persists with
10
+ # that aggregate's records instead (see {SagaAnchor}), so a saga lives where the record
11
+ # that starts it lives.
8
12
  module SagaPersistence
9
- # Memoized per domain via double-checked locking; never reuse @saga_mutex, see #initialize.
10
- def saga_persistence(domain)
11
- key = domain.to_s
13
+ include SagaAnchor
14
+
15
+ # The store one domain's sagas, or one of its process managers, checkpoint to. Memoized
16
+ # per store via double-checked locking; never reuse @saga_mutex, see #initialize.
17
+ #
18
+ # @param domain [String, Symbol] the domain whose sagas are checkpointed
19
+ # @param process_manager [Bluebook::ProcessManager, String, nil] one process manager,
20
+ # whose starting aggregate may hold it; the domain's own store when nil
21
+ # @return [#save_saga] the adapter, or the null store when nothing can checkpoint
22
+ def saga_persistence(domain, process_manager = nil)
23
+ anchor = process_manager && saga_anchor(domain, process_manager)
24
+ key = anchor ? "#{domain}::#{anchor.hecks_name}" : domain.to_s
12
25
  @saga_persistence[key] || @saga_persistence_mutex.synchronize do
13
- @saga_persistence[key] ||= resolve_saga_persistence(key)
26
+ @saga_persistence[key] ||= anchor ? anchored_saga_store(domain, anchor) : resolve_saga_persistence(key)
14
27
  end
15
28
  end
16
29
 
17
30
  def saga_domains = @declared.hecksagons.keys | @declared.bluebooks.keys.select { |domain| default_adapter_for(domain) }
18
31
 
32
+ # Whether any saga of the domain checkpoints somewhere that survives a restart.
33
+ #
34
+ # @param domain [String, Symbol] the domain whose sagas are asked about
35
+ # @return [Boolean]
36
+ def saga_durable?(domain)
37
+ stores = [saga_persistence(domain), *routed_sagas(domain).values]
38
+ stores.any? { |store| !store.equal?(Ports::Persistence::NULL_SAGA_STORE) }
39
+ end
40
+
19
41
  # Boot-time-only (Loader.run_boot_gates!, before dispatch exists); no concurrent
20
- # writer to race here, unlike saga_interpreter.rb's writes under @saga_mutex.
42
+ # writer to race here, unlike saga_interpreter.rb's writes under @saga_mutex. Each store
43
+ # restores only the process managers that checkpoint to it; the domain's own store
44
+ # restores every other one, including any the bluebook does not declare.
21
45
  def rehydrate_sagas!
22
46
  saga_domains.each do |domain|
23
- saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
24
- pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
25
- @state.saga_instances[process_manager][correlation] =
26
- { state: state, memory: memory, completed_compensations: completed_compensations || [] }
27
- stall_saga(process_manager, correlation, pending) if pending
28
- end
47
+ routed = routed_sagas(domain)
48
+ restore_sagas(saga_persistence(domain)) { |name| !routed.key?(name) }
49
+ routed.values.uniq.each { |store| restore_sagas(store) { |name| routed[name].equal?(store) } }
29
50
  end
30
51
  self
31
52
  end
32
53
 
33
54
  private
34
55
 
56
+ # Restores the instances a store holds whose process manager the block accepts.
57
+ def restore_sagas(store)
58
+ store.each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
59
+ next unless yield(process_manager.to_s)
60
+
61
+ pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
62
+ @state.saga_instances[process_manager][correlation] =
63
+ { state: state, memory: memory, completed_compensations: completed_compensations || [] }
64
+ stall_saga(process_manager, correlation, pending) if pending
65
+ end
66
+ end
67
+
35
68
  # A pending marker left by a crash: state was checkpointed but the leg's dispatches did
36
69
  # not all settle. Held on the instance for `SagaInterpreter#redrive_stalled`, which runs
37
70
  # once the dispatcher exists; Heki's each_saga only symbolizes memory's top-level keys,
@@ -40,8 +73,10 @@ module Hecks
40
73
  @state.saga_instances[process_manager][correlation][:stalled] = pending.transform_keys(&:to_sym)
41
74
  end
42
75
 
76
+ # A domain with no hecksagon resolves the same way: its world's `default_adapter` may keep
77
+ # its records, and then it keeps its sagas too (ADR 0170).
43
78
  def resolve_saga_persistence(domain)
44
- anchor = hecksagon(domain) && bluebook(domain)&.aggregates&.first
79
+ anchor = bluebook(domain)&.aggregates&.first
45
80
  return Ports::Persistence::NULL_SAGA_STORE unless anchor
46
81
 
47
82
  persisting_adapter(domain, anchor)
@@ -50,6 +85,17 @@ module Hecks
50
85
  Ports::Persistence::NULL_SAGA_STORE
51
86
  end
52
87
 
88
+ # The starting aggregate's store when it can checkpoint sagas, else the domain's own.
89
+ # Called under the resolution mutex, so the domain's store is memoized here directly.
90
+ def anchored_saga_store(domain, anchor)
91
+ store = persisting_adapter(domain, anchor)
92
+ store.equal?(Ports::Persistence::NULL_SAGA_STORE) ? domain_saga_store(domain) : store
93
+ rescue WiringError
94
+ domain_saga_store(domain)
95
+ end
96
+
97
+ def domain_saga_store(domain) = (@saga_persistence[domain.to_s] ||= resolve_saga_persistence(domain.to_s))
98
+
53
99
  # The anchor aggregate's own adapter when it can checkpoint sagas, else the null store.
54
100
  def persisting_adapter(domain, anchor)
55
101
  bind = Ports::Persistence::BindingPolicy.resolve(self, domain, anchor)
@@ -6,6 +6,7 @@ require_relative "query_verification"
6
6
  require_relative "plugin_query_verification"
7
7
  require_relative "bluebook_verification"
8
8
  require_relative "durability_warnings"
9
+ require_relative "saga_durability_rule"
9
10
 
10
11
  module Hecks
11
12
  module Runtime
@@ -19,6 +20,7 @@ module Hecks
19
20
  include PluginQueryVerification
20
21
  include BluebookVerification
21
22
  include DurabilityWarnings
23
+ include SagaDurabilityRule
22
24
 
23
25
  # Runs every wiring check against this registry's loaded bluebooks, hecksagons, ports
24
26
  # and adapters.
@@ -31,9 +33,8 @@ module Hecks
31
33
  refuse_membership_without_identity!
32
34
  refuse_unresolved_ports!
33
35
  refuse_unanswerable_queries!
34
-
35
- @declared.hecksagons.each_value { |hecksagon| verify_hecksagon!(hecksagon) }
36
- self
36
+ refuse_forgetful_sagas!
37
+ verify_hecksagons!
37
38
  end
38
39
 
39
40
  # Checks that the framework-wide default persistence adapter is itself wired correctly.
@@ -127,6 +128,9 @@ module Hecks
127
128
 
128
129
  private
129
130
 
131
+ # Every loaded hecksagon's checks, answering the registry.
132
+ def verify_hecksagons! = tap { @declared.hecksagons.each_value { |hecksagon| verify_hecksagon!(hecksagon) } }
133
+
130
134
  # One hecksagon's checks: its roles, attachments and ACL, every bind, and the durability
131
135
  # warnings for its bluebook.
132
136
  def verify_hecksagon!(hecksagon)
@@ -53,7 +53,8 @@ module Hecks
53
53
  @registry.saga_mutex.synchronize do
54
54
  next false unless @registry.saga_instances[process_manager.name].delete(correlation)
55
55
 
56
- @registry.saga_persistence(domain).delete_saga(process_manager: process_manager.name, correlation: correlation)
56
+ @registry.saga_persistence(domain, process_manager)
57
+ .delete_saga(process_manager: process_manager.name, correlation: correlation)
57
58
  true
58
59
  end
59
60
  end
@@ -168,7 +168,7 @@ module Hecks
168
168
  def checkpoint(process_manager, correlation, instance, domain, pending: nil)
169
169
  memory = deep_copy(instance[:memory])
170
170
  memory[SAGA_PENDING_DISPATCH_KEY] = pending if pending
171
- @registry.saga_persistence(domain).save_saga(
171
+ @registry.saga_persistence(domain, process_manager).save_saga(
172
172
  process_manager: process_manager.name, correlation: correlation,
173
173
  state: instance[:state], memory: memory,
174
174
  completed_compensations: deep_copy_array(instance[:completed_compensations])
@@ -22,7 +22,8 @@ module Hecks
22
22
  # The most passes `call` makes; a text still changing after them keeps the last result.
23
23
  PASSES = 3
24
24
 
25
- # Serializes the rustfmt children of this process. In a whole-suite run, worker threads capturing
25
+ # Serializes the rustfmt children of this process. In a whole-suite run, worker threads
26
+ # capturing
26
27
  # at once end with `Errno::EBADF` in `Open3.popen_run`, so each capture waits for this lock.
27
28
  SPAWN_LOCK = Mutex.new
28
29