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
@@ -0,0 +1,536 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pipeline Wiring</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink2: #52514e; --muted: #898781;
11
+ --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,.10);
12
+ --series1: #2a78d6; --good: #0ca30c; --warning: #fab219; --critical: #d03b3b;
13
+ }
14
+ @media (prefers-color-scheme: dark) {
15
+ :root:not([data-theme="light"]) {
16
+ color-scheme: dark;
17
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink2: #c3c2b7; --muted: #898781;
18
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10); --series1: #3987e5;
19
+ }
20
+ }
21
+ :root[data-theme="dark"] {
22
+ color-scheme: dark;
23
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink2: #c3c2b7; --muted: #898781;
24
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10); --series1: #3987e5;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
28
+ main { max-width: 1280px; margin: 0 auto; padding: 16px; }
29
+ header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }
30
+ h1 { font-size: 20px; margin: 0; font-weight: 600; }
31
+ a { color: var(--series1); }
32
+ .meta { color: var(--ink2); font-size: 13px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
33
+ button { font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--axis); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
34
+ button:focus-visible, a:focus-visible { outline: 2px solid var(--series1); outline-offset: 2px; }
35
+ section { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 16px; }
36
+ .legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--ink2); margin-bottom: 8px; }
37
+ .legend b { color: var(--ink); font-weight: 600; }
38
+ .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
39
+ canvas { width: 100%; height: 720px; display: block; touch-action: none; border: 1px solid var(--grid); border-radius: 8px; background: var(--page); }
40
+ .log { list-style: none; margin: 0; padding: 0; font-size: 13px; max-height: 260px; overflow: auto; }
41
+ .log li { padding: 4px 0; border-bottom: 1px solid var(--grid); overflow-wrap: anywhere; }
42
+ .log time { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: 8px; }
43
+ .log .mark { font-weight: 600; margin-left: 6px; }
44
+ h2 { font-size: 13px; margin: 0 0 8px; color: var(--ink2); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
45
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
46
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--grid); }
47
+ th { color: var(--ink2); font-size: 12px; }
48
+ .scroll { overflow: auto; max-height: 420px; }
49
+ .tables-only canvas { display: none; } body:not(.tables-only) #edgetable { display: none; }
50
+ .tip { position: fixed; pointer-events: none; background: var(--ink); color: var(--surface); padding: 4px 8px; border-radius: 6px; font-size: 12px; z-index: 10; display: none; max-width: 300px; }
51
+ .unknown-note { color: var(--ink2); font-style: italic; }
52
+ .panel { position: fixed; top: 0; right: 0; width: 400px; max-width: 100vw; height: 100vh; overflow: auto; background: var(--surface); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,.18); padding: 14px 16px 24px; z-index: 20; }
53
+ .panel[hidden] { display: none; }
54
+ .panel .top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
55
+ .panel h2 { font-size: 16px; margin: 0; color: var(--ink); text-transform: none; letter-spacing: 0; }
56
+ .panel h3 { font-size: 12px; margin: 16px 0 6px; color: var(--ink2); text-transform: uppercase; letter-spacing: .04em; }
57
+ .panel .chip { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--border); color: var(--ink2); margin-right: 6px; }
58
+ .panel .desc { color: var(--ink2); font-size: 13px; }
59
+ .panel .stat { display: flex; gap: 14px; flex-wrap: wrap; font-size: 13px; }
60
+ .panel .stat b { font-size: 20px; font-weight: 600; display: block; }
61
+ .panel ul { list-style: none; margin: 0; padding: 0; font-size: 13px; }
62
+ .panel li { padding: 4px 0; border-bottom: 1px solid var(--grid); overflow-wrap: anywhere; }
63
+ .panel li small { color: var(--muted); }
64
+ .panel .ev time { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: 6px; font-size: 12px; }
65
+ .panel .ev .re { display: block; color: var(--ink2); font-size: 12px; padding-left: 14px; }
66
+ .panel .mark { font-weight: 600; }
67
+ .panel .states span { display: inline-block; border: 1px solid var(--axis); border-radius: 4px; padding: 1px 6px; margin: 2px 4px 2px 0; font-size: 12px; }
68
+ .panel .link { background: none; border: none; color: var(--series1); padding: 0; cursor: pointer; text-decoration: underline; }
69
+ @media (max-width: 700px) { canvas { height: 460px; } }
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <main>
74
+ <header>
75
+ <h1>Pipeline Wiring</h1>
76
+ <div class="meta">
77
+ <a href="/">Board</a><a href="/sequence">Sequence</a>
78
+ <span id="count">loading the wiring</span>
79
+ <span id="live">stream: connecting</span>
80
+ <button id="zout" type="button" aria-label="Zoom out">&minus;</button>
81
+ <button id="zin" type="button" aria-label="Zoom in">+</button>
82
+ <button id="fit" type="button">Fit</button>
83
+ <button id="all" type="button">Expand all</button>
84
+ <button id="bounds" type="button" aria-pressed="true">Hide boundaries</button>
85
+ <button id="replay" type="button">Replay recent</button>
86
+ <button id="toggle" type="button" aria-pressed="false">Show table</button>
87
+ </div>
88
+ </header>
89
+ <section aria-labelledby="h-map">
90
+ <h2 id="h-map">Aggregates, ports and adapters, with messages as they move</h2>
91
+ <div class="legend" id="legend">
92
+ <span><b>&#9645;</b> aggregate</span><span><b>&#9671;</b> port</span><span><b>&#11042;</b> adapter</span>
93
+ <span><b style="color:var(--good)">&#10003;</b> delivered</span>
94
+ <span><b style="color:var(--warning)">&#9650;</b> waiting</span>
95
+ <span><b style="color:var(--critical)">&#10005;</b> refused</span>
96
+ <span>a line rests in its domain's colour, lights in the outcome's colour when a message crosses it, and fades over 20 s</span>
97
+ <span>each context is one block with its aggregate count; click a block to open it, click its name to close it; scroll to zoom (zooming in opens everything), drag to pan; packed shapes move aside under the pointer, and a click opens the sidebar</span>
98
+ </div>
99
+ <div class="legend" id="domains"></div>
100
+ <canvas id="map" role="img" aria-label="Wiring diagram: policies between aggregates, ports and adapters, with live messages"></canvas>
101
+ <div class="scroll" id="edgetable"></div>
102
+ </section>
103
+ <section aria-labelledby="h-log"><h2 id="h-log">Messages</h2><ul class="log" id="log" aria-live="polite"></ul></section>
104
+ </main>
105
+ <div class="tip" id="tip" role="tooltip"></div>
106
+ <aside class="panel" id="panel" hidden aria-label="Selected node">
107
+ <div class="top"><h2 id="ptitle"></h2><span><button id="focus" type="button" aria-pressed="false">Focus</button> <button id="pclose" type="button" aria-label="Close">&#10005;</button></span></div>
108
+ <div id="pbody"></div>
109
+ </aside>
110
+ <script>
111
+ "use strict";
112
+ const $ = (id) => document.getElementById(id);
113
+ const canvas = $("map"), ctx = canvas.getContext("2d"), tip = $("tip");
114
+ const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
115
+ const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
116
+ let nodes = [], edges = [], byId = new Map(), view = { s: 1, x: 0, y: 0 }, hover = null;
117
+ const pulses = new Map(), particles = [], seen = new Map();
118
+ /* Categorical slots from the reference palette, in their fixed order; a 9th domain folds to muted. */
119
+ const SLOTS = { light: ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"],
120
+ dark: ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"] };
121
+ let domains = [], domainColour = new Map(), showBounds = true;
122
+ /* A lit line or node stays at full for LINGER - LINGER_FADE ms, then fades over LINGER_FADE ms. */
123
+ const LINGER = 22000, LINGER_FADE = 14000;
124
+ const dark = () => matchMedia("(prefers-color-scheme: dark)").matches && document.documentElement.dataset.theme !== "light" || document.documentElement.dataset.theme === "dark";
125
+ function colourOf(domain) { const i = domainColour.get(domain); return i === undefined || i > 7 ? css("--muted") : SLOTS[dark() ? "dark" : "light"][i]; }
126
+
127
+ function h(tag, attrs, ...kids) {
128
+ const el = document.createElement(tag);
129
+ for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
130
+ for (const kid of kids.flat()) if (kid !== null && kid !== undefined) el.append(kid.nodeType ? kid : String(kid));
131
+ return el;
132
+ }
133
+ function clock(iso) { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
134
+
135
+ /* ---- domains: an adapter belongs to the domain of the port it answers, but sits outside its hull ---- */
136
+ /* A context is a bluebook's domain module when it declares one, else the bluebook itself. */
137
+ const keyOf = (n) => n.context ? n.domain + " / " + n.context : n.domain;
138
+ let contexts = [];
139
+ function assignDomains() {
140
+ for (const e of edges) if (e.kind === "adapter") { const p = byId.get(e.from), a = byId.get(e.to); if (p && a) { a.domain = p.domain; a.context = p.context; } }
141
+ const count = new Map(), ctx = new Map();
142
+ for (const n of nodes) if (n.kind !== "adapter") { count.set(n.domain, (count.get(n.domain) || 0) + 1); ctx.set(keyOf(n), (ctx.get(keyOf(n)) || 0) + 1); }
143
+ domains = [...count.keys()].sort((a, b) => count.get(b) - count.get(a));
144
+ domainColour = new Map(domains.map((d, i) => [d, i]));
145
+ contexts = [...ctx.keys()].sort((a, b) => ctx.get(b) - ctx.get(a));
146
+ }
147
+ /* ---- layout: domains on a ring, their contexts on a ring around each, aggregates inside, ports on the rim, adapters outside ---- */
148
+ let centre = new Map();
149
+ function layout() {
150
+ centre = new Map();
151
+ domains.forEach((d, i) => {
152
+ const mine = contexts.filter((k) => k === d || k.startsWith(d + " / ")), size = nodes.filter((n) => n.domain === d).length;
153
+ const c = { x: Math.cos(i / domains.length * 6.283) * (640 + size * 3), y: Math.sin(i / domains.length * 6.283) * (460 + size * 2) };
154
+ const r = mine.length > 1 ? 90 + mine.length * 16 : 0;
155
+ mine.forEach((k, j) => centre.set(k, { x: c.x + Math.cos(j / mine.length * 6.283) * r, y: c.y + Math.sin(j / mine.length * 6.283) * r }));
156
+ });
157
+ nodes.forEach((n, i) => { const c = centre.get(keyOf(n)) || { x: 0, y: 0 }; n.x = c.x + Math.cos(i * 2.4) * 30; n.y = c.y + Math.sin(i * 2.4) * 30; n.vx = 0; n.vy = 0; });
158
+ for (let step = 0; step < 400; step++) tick(1 - step / 420);
159
+ }
160
+ function tick(heat) {
161
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
162
+ const a = nodes[i], b = nodes[j]; let dx = a.x - b.x, dy = a.y - b.y; const d2 = Math.max(dx * dx + dy * dy, 36);
163
+ const f = (keyOf(a) === keyOf(b) ? 1800 : a.domain === b.domain ? 7000 : 5200) / d2, d = Math.sqrt(d2); dx /= d; dy /= d;
164
+ a.vx += dx * f; a.vy += dy * f; b.vx -= dx * f; b.vy -= dy * f;
165
+ }
166
+ for (const e of edges) {
167
+ const a = byId.get(e.from), b = byId.get(e.to); if (!a || !b) continue;
168
+ const rest = e.kind === "adapter" ? 30 : 60, dx = b.x - a.x, dy = b.y - a.y, d = Math.max(Math.hypot(dx, dy), 1);
169
+ const f = (d - rest) * (a.domain === b.domain && keyOf(a) !== keyOf(b) ? 0.004 : 0.01);
170
+ a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f;
171
+ }
172
+ for (const n of nodes) {
173
+ const c = centre.get(keyOf(n)) || { x: 0, y: 0 }, dx = n.x - c.x, dy = n.y - c.y, d = Math.max(Math.hypot(dx, dy), 1);
174
+ const pull = n.kind === "aggregate" ? 0.02 : n.kind === "port" ? 0.01 : 0.005;
175
+ n.vx += (c.x - n.x) * pull; n.vy += (c.y - n.y) * pull;
176
+ if (n.kind !== "aggregate") { const out = n.kind === "port" ? 0.9 : 1.6; n.vx += dx / d * out; n.vy += dy / d * out; }
177
+ n.x += n.vx * 0.5 * heat; n.y += n.vy * 0.5 * heat; n.vx *= 0.82; n.vy *= 0.82;
178
+ }
179
+ }
180
+ /* ---- boundaries: a padded convex hull around each domain's aggregates and ports ---- */
181
+ function hull(points) {
182
+ const p = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); if (p.length < 3) return p;
183
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
184
+ const lower = []; for (const q of p) { while (lower.length > 1 && cross(lower[lower.length - 2], lower[lower.length - 1], q) <= 0) lower.pop(); lower.push(q); }
185
+ const upper = []; for (const q of p.slice().reverse()) { while (upper.length > 1 && cross(upper[upper.length - 2], upper[upper.length - 1], q) <= 0) upper.pop(); upper.push(q); }
186
+ return lower.slice(0, -1).concat(upper.slice(0, -1));
187
+ }
188
+ function outline(members, pad) {
189
+ const cx = members.reduce((s, q) => s + q[0], 0) / members.length, cy = members.reduce((s, q) => s + q[1], 0) / members.length;
190
+ const ring = hull(members).map(([x, y]) => { const dx = x - cx, dy = y - cy, l = Math.max(Math.hypot(dx, dy), 1); return [x + dx / l * pad, y + dy / l * pad]; });
191
+ ctx.beginPath();
192
+ if (ring.length === 1) ctx.arc(cx, cy, pad, 0, 6.283);
193
+ else if (ring.length === 2) ctx.ellipse(cx, cy, Math.hypot(ring[0][0] - ring[1][0], ring[0][1] - ring[1][1]) / 2 + pad, pad, Math.atan2(ring[1][1] - ring[0][1], ring[1][0] - ring[0][0]), 0, 6.283);
194
+ else { const m = (i) => [(ring[i][0] + ring[(i + 1) % ring.length][0]) / 2, (ring[i][1] + ring[(i + 1) % ring.length][1]) / 2];
195
+ ctx.moveTo(...m(ring.length - 1)); for (let i = 0; i < ring.length; i++) ctx.quadraticCurveTo(ring[i][0], ring[i][1], ...m(i)); }
196
+ ctx.closePath();
197
+ return { cx, top: Math.min(...members.map((q) => q[1])) - pad - 4 };
198
+ }
199
+ function paint(colour, fillAlpha, strokeAlpha, dash) {
200
+ ctx.globalAlpha = fillAlpha; ctx.fillStyle = colour; ctx.fill();
201
+ ctx.globalAlpha = strokeAlpha; ctx.lineWidth = 1.5; ctx.strokeStyle = colour; ctx.setLineDash(dash); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;
202
+ }
203
+ function caption(text, x, y, weight, active, now) {
204
+ ctx.font = weight + " 12px system-ui, sans-serif"; ctx.fillStyle = active ? css("--series1") : css("--ink"); ctx.textAlign = "center";
205
+ ctx.globalAlpha = active && !blink(now) ? 0.25 : 1; ctx.fillText(text, x, y); ctx.globalAlpha = 1; ctx.textAlign = "start";
206
+ }
207
+ function corners(b) { return [[b.x - b.w / 2, b.y - b.hgt / 2], [b.x + b.w / 2, b.y - b.hgt / 2], [b.x + b.w / 2, b.y + b.hgt / 2], [b.x - b.w / 2, b.y + b.hgt / 2]]; }
208
+ function drawBounds(now) {
209
+ const inside = (n) => n.kind !== "adapter";
210
+ const active = (test) => nodes.some((n) => test(n) && heatOf(n.id, now) > 0);
211
+ for (const d of domains) {
212
+ const split = contexts.filter((k) => k.startsWith(d + " / ")).length > 1; if (!split) continue;
213
+ const members = contexts.filter((k) => k === d || k.startsWith(d + " / ")).flatMap((k) => blocks.has(k) ? corners(blocks.get(k)) : nodes.filter((n) => keyOf(n) === k && inside(n)).map((n) => [sx(n), sy(n)]));
214
+ if (!members.length) continue;
215
+ const at = outline(members, 36); paint(colourOf(d), 0.04, 0.35, [2, 6]); caption(d, at.cx, at.top, "700", active((n) => n.domain === d), now);
216
+ }
217
+ for (const k of contexts) {
218
+ if (blocks.has(k)) continue;
219
+ const members = nodes.filter((n) => keyOf(n) === k && inside(n)).map((n) => [sx(n), sy(n)]);
220
+ if (!members.length) continue;
221
+ const d = k.split(" / ")[0], name = k.split(" / ")[1] || d;
222
+ const at = outline(members, 20); paint(colourOf(d), 0.09, 0.7, [6, 4]); caption(name + " −", at.cx, at.top, "600", active((n) => keyOf(n) === k), now);
223
+ ctx.font = "600 12px system-ui, sans-serif"; const w = ctx.measureText(name + " −").width;
224
+ captions.push({ k, x: at.cx - w / 2, y: at.top - 12, w, hgt: 16 });
225
+ }
226
+ }
227
+ let touched = false, fitScale = 1;
228
+ function size() {
229
+ const r = canvas.getBoundingClientRect(), dpr = devicePixelRatio || 1;
230
+ canvas.width = r.width * dpr; canvas.height = r.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
231
+ }
232
+ function fit() {
233
+ size(); touched = false;
234
+ const r = canvas.getBoundingClientRect();
235
+ if (!nodes.length) return;
236
+ const xs = nodes.map((n) => n.x), ys = nodes.map((n) => n.y);
237
+ const w = Math.max(...xs) - Math.min(...xs) + 120, hgt = Math.max(...ys) - Math.min(...ys) + 120;
238
+ view.s = Math.min(r.width / w, r.height / hgt); fitScale = view.s;
239
+ view.x = r.width / 2 - (Math.max(...xs) + Math.min(...xs)) / 2 * view.s;
240
+ view.y = r.height / 2 - (Math.max(...ys) + Math.min(...ys)) / 2 * view.s;
241
+ }
242
+ /* A node's place on screen, plus the scoot: nodes within reach of the cursor ease away from it radially,
243
+ so a packed cluster opens under the pointer and closes when it leaves. The lines follow. */
244
+ const sx = (n) => n.x * view.s + view.x + (n.ox || 0), sy = (n) => n.y * view.s + view.y + (n.oy || 0);
245
+ const SCOOT_REACH = 90, SCOOT_PUSH = 0.6;
246
+ let cursor = null;
247
+ const base = (n) => ({ x: n.x * view.s + view.x, y: n.y * view.s + view.y });
248
+ function scoot() {
249
+ /* Nothing moves until the pointer is on a shape; then that shape stays and its neighbours make room. */
250
+ let pinned = null, nearest = 14;
251
+ if (cursor) for (const n of nodes) { if (blocks.has(keyOf(n))) continue; const b = base(n), d = Math.hypot(b.x - cursor.x, b.y - cursor.y); if (d < nearest) { nearest = d; pinned = n; } }
252
+ for (const n of nodes) {
253
+ let tx = 0, ty = 0;
254
+ if (pinned && n !== pinned && !blocks.has(keyOf(n)) && !reduceMotion) {
255
+ const b = base(n), dx = b.x - cursor.x, dy = b.y - cursor.y, d = Math.hypot(dx, dy);
256
+ if (d < SCOOT_REACH && d > 0.5) { const push = (SCOOT_REACH - d) * SCOOT_PUSH; tx = dx / d * push; ty = dy / d * push; }
257
+ }
258
+ n.ox = (n.ox || 0) + (tx - (n.ox || 0)) * 0.22; n.oy = (n.oy || 0) + (ty - (n.oy || 0)) * 0.22;
259
+ }
260
+ }
261
+ const scooted = (n) => Math.hypot(n.ox || 0, n.oy || 0) > 6;
262
+ /* ---- zoom with the wheel around the cursor, pan by dragging, or use the buttons ---- */
263
+ function zoomAt(mx, my, k) { view.x = mx - (mx - view.x) * k; view.y = my - (my - view.y) * k; view.s *= k; touched = true; }
264
+ function zoomCentre(k) { const r = canvas.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, k); }
265
+ let drag = null;
266
+ canvas.addEventListener("wheel", (e) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0015)); }, { passive: false });
267
+ canvas.addEventListener("mousedown", (e) => { drag = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false }; });
268
+ addEventListener("mousemove", (e) => { if (!drag) return; drag.moved = true; view.x = drag.vx + e.clientX - drag.x; view.y = drag.vy + e.clientY - drag.y; touched = true; });
269
+ addEventListener("mouseup", (e) => {
270
+ const was = drag; drag = null; if (!was || was.moved || e.target !== canvas) return;
271
+ const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
272
+ const hit = [...blocks.values()].find((b) => Math.abs(mx - b.x) < b.w / 2 && Math.abs(my - b.y) < b.hgt / 2);
273
+ if (hit) { expanded.add(hit.k); return; }
274
+ const cap = captions.find((c) => mx >= c.x && mx <= c.x + c.w && my >= c.y && my <= c.y + c.hgt);
275
+ if (cap) { expanded.delete(cap.k); allOpen = false; $("all").textContent = "Expand all"; return; }
276
+ if (hover) openPanel(hover);
277
+ });
278
+
279
+ /* ---- the sidebar: what a node declares, and what it is doing right now ---- */
280
+ const recent = [];
281
+ let selected = null, focusId = null, panelTimer = null;
282
+ /* The stream sends an event again when its reactions settle, so a key is kept once, at its newest. */
283
+ function remember(ev) {
284
+ const i = recent.findIndex((held) => held.key === ev.key);
285
+ if (i >= 0) recent[i] = ev; else { recent.push(ev); if (recent.length > 800) recent.shift(); }
286
+ }
287
+ function touches(ev, n) {
288
+ if (n.kind === "aggregate") return ev.aggregate === n.id || (ev.reactions || []).some((r) => String(r.command).startsWith(n.id + "."));
289
+ const stem = n.kind === "port" ? "::" + n.label + "." : null;
290
+ return stem ? (ev.reactions || []).some((r) => String(r.command).includes(stem)) : false;
291
+ }
292
+ function openPanel(n) {
293
+ selected = n; $("panel").hidden = false; $("ptitle").textContent = n.label; renderPanel();
294
+ clearInterval(panelTimer); panelTimer = setInterval(renderPanel, 2000);
295
+ try { history.replaceState(null, "", "#" + encodeURIComponent(n.id)); } catch (e) { /* history may be unavailable */ }
296
+ }
297
+ function closePanel() { selected = null; focusId = null; $("panel").hidden = true; $("focus").setAttribute("aria-pressed", "false"); clearInterval(panelTimer); try { history.replaceState(null, "", location.pathname); } catch (e) { /* ignore */ } }
298
+ function section(title, ...kids) { return [h("h3", {}, title), ...kids]; }
299
+ function nodeLink(id) { const n = byId.get(id); return h("button", { class: "link", type: "button", "data-id": id }, n ? n.label : id); }
300
+ function renderPanel() {
301
+ const n = selected; if (!n) return;
302
+ const d = n.details || {}, now = Date.now(), mine = recent.filter((ev) => touches(ev, n)), tenMin = mine.filter((ev) => now - Date.parse(ev.at) < 600000);
303
+ const outcomes = mine.flatMap((ev) => ev.reactions || []), refused = outcomes.filter((r) => /refus|fail/.test(r.outcome || "")).length;
304
+ const last = mine.map((ev) => ev.at).sort().pop();
305
+ const body = [
306
+ h("div", {}, h("span", { class: "chip", style: "border-color:" + colourOf(n.domain) + ";color:" + colourOf(n.domain) }, keyOf(n)), h("span", { class: "chip" }, n.kind)),
307
+ d.description ? h("p", { class: "desc" }, d.description) : null,
308
+ d.vision ? h("p", { class: "desc" }, h("i", {}, d.vision)) : null,
309
+ ...section("Live", h("div", { class: "stat" },
310
+ h("span", {}, h("b", {}, String(tenMin.length)), "events, 10 min"),
311
+ h("span", {}, h("b", {}, String(outcomes.length - refused)), "delivered"),
312
+ h("span", {}, h("b", { style: refused ? "color:var(--critical)" : "" }, String(refused)), "refused"),
313
+ h("span", {}, h("b", {}, last ? clock(last) : "—"), "last seen")),
314
+ h("ul", {}, mine.slice(-12).reverse().map((ev) => h("li", { class: "ev" }, h("time", {}, clock(ev.at)), h("b", {}, ev.name), " ", h("small", {}, ev.aggregate.split("::").pop() + "#" + String(ev.id).slice(0, 8)),
315
+ (ev.reactions || []).map((r) => { const k = outcomeKind(r.outcome || ""); return h("span", { class: "re" }, h("span", { class: "mark", style: "color:var(" + OUTCOME[k][0] + ")" }, OUTCOME[k][1].slice(0, 1)), " " + String(r.policy).split("::").pop() + (r.command ? " → " + String(r.command).split("::").pop() : "")); }))))),
316
+ ];
317
+ if (n.kind === "aggregate") body.push(...declaredAggregate(d));
318
+ if (n.kind === "port") body.push(...section("Operations", h("ul", {}, (d.operations || []).map((o) => h("li", {}, h("b", {}, o.name), " ", h("small", {}, o.direction), o.answers ? " answers " + o.answers : "", o.refuses ? ", refuses " + o.refuses : "")))));
319
+ if (n.kind === "adapter") body.push(...section("Binds", h("ul", {}, h("li", {}, "port ", h("b", {}, d.port || "")), (d.fields || []).length ? h("li", {}, "settings: " + d.fields.join(", ")) : null)));
320
+ body.push(...wiringOf(n));
321
+ $("pbody").replaceChildren(...body.flat().filter(Boolean));
322
+ }
323
+ function declaredAggregate(d) {
324
+ const out = [];
325
+ if (d.lifecycle) out.push(...section("Lifecycle: " + d.lifecycle.field, h("div", { class: "states" }, d.lifecycle.states.map((s) => h("span", { style: s === d.lifecycle.default ? "border-color:var(--series1)" : "" }, s)))));
326
+ if ((d.commands || []).length) out.push(...section("Commands", h("ul", {}, d.commands.map((c) => h("li", {}, h("b", {}, c.name), " ", c.goal, c.emits.length ? h("small", {}, " → " + c.emits.join(", ")) : null)))));
327
+ if ((d.queries || []).length) out.push(...section("Queries", h("ul", {}, d.queries.map((q) => h("li", {}, h("b", {}, q.name), " ", h("small", {}, q.description))))));
328
+ return out;
329
+ }
330
+ function wiringOf(n) {
331
+ const into = edges.filter((e) => e.to === n.id && e.kind !== "adapter"), out = edges.filter((e) => e.from === n.id && e.kind !== "adapter"), bound = edges.filter((e) => e.kind === "adapter" && (e.from === n.id || e.to === n.id));
332
+ return [
333
+ ...(into.length ? section("Triggered by", h("ul", {}, into.map((e) => h("li", {}, nodeLink(e.from), " → ", h("b", {}, e.label))))) : []),
334
+ ...(out.length ? section("Reacts to its events", h("ul", {}, out.map((e) => h("li", {}, h("b", {}, e.label), " → ", nodeLink(e.to), e.kind === "ask" ? h("small", {}, " (ask)") : null)))) : []),
335
+ ...(bound.length ? section("Bound", h("ul", {}, bound.map((e) => h("li", {}, nodeLink(e.from), " ↔ ", nodeLink(e.to))))) : []),
336
+ ];
337
+ }
338
+ $("pbody").addEventListener("click", (e) => { const id = e.target.dataset && e.target.dataset.id; if (!id) return; const n = byId.get(id); if (!n) return; expanded.add(keyOf(n)); openPanel(n); });
339
+ $("pclose").addEventListener("click", closePanel);
340
+ $("focus").addEventListener("click", () => { focusId = focusId ? null : selected && selected.id; $("focus").setAttribute("aria-pressed", String(!!focusId)); });
341
+ addEventListener("keydown", (e) => { if (e.key === "Escape") closePanel(); });
342
+ canvas.addEventListener("dblclick", (e) => { const r = canvas.getBoundingClientRect(); zoomAt(e.clientX - r.left, e.clientY - r.top, 1.6); });
343
+ let placed = [];
344
+ function free(x, y, w, hgt) {
345
+ if (placed.some((p) => x < p.x + p.w && x + w > p.x && y < p.y + p.hgt && y + hgt > p.y)) return false;
346
+ placed.push({ x, y, w, hgt }); return true;
347
+ }
348
+
349
+ /* ---- drawing ---- */
350
+ function shape(n, x, y, r) {
351
+ ctx.beginPath();
352
+ if (n.kind === "port") { ctx.moveTo(x, y - r); ctx.lineTo(x + r, y); ctx.lineTo(x, y + r); ctx.lineTo(x - r, y); ctx.closePath(); }
353
+ else if (n.kind === "adapter") { for (let i = 0; i < 6; i++) { const a = i / 6 * 6.283; ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); } ctx.closePath(); }
354
+ else ctx.roundRect(x - r, y - r * 0.7, r * 2, r * 1.4, 3);
355
+ }
356
+ /* ---- level of detail: a context is one block until it is opened by a click, "Expand all", or zooming in ---- */
357
+ const expanded = new Set();
358
+ let blocks = new Map(), captions = [], allOpen = false;
359
+ const open = (k) => allOpen || expanded.has(k) || view.s >= fitScale * 2.2;
360
+ function blockOf(k) {
361
+ const members = nodes.filter((n) => keyOf(n) === k && n.kind !== "adapter");
362
+ if (!members.length) return null;
363
+ const x = members.reduce((s, n) => s + sx(n), 0) / members.length, y = members.reduce((s, n) => s + sy(n), 0) / members.length;
364
+ const d = k.split(" / ")[0], name = k.split(" / ")[1] || d, count = members.filter((n) => n.kind === "aggregate").length;
365
+ ctx.font = "600 12px system-ui, sans-serif";
366
+ const w = Math.max(ctx.measureText(name).width + 34, 64), hgt = 30;
367
+ return { k, d, name, count, x, y, w, hgt, members };
368
+ }
369
+ const anchor = (n) => { const b = blocks.get(keyOf(n)); return b ? { x: b.x, y: b.y } : { x: sx(n), y: sy(n) }; };
370
+ /* Blocks are pixel-sized, so two neighbouring contexts can collide at the fitted scale: push them apart. */
371
+ function separate(list) {
372
+ for (let pass = 0; pass < 12; pass++) for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
373
+ const a = list[i], b = list[j], gapX = (a.w + b.w) / 2 + 10 - Math.abs(a.x - b.x), gapY = (a.hgt + b.hgt) / 2 + 8 - Math.abs(a.y - b.y);
374
+ if (gapX <= 0 || gapY <= 0) continue;
375
+ if (gapX < gapY) { const s = Math.sign(a.x - b.x) || 1; a.x += s * gapX / 2; b.x -= s * gapX / 2; }
376
+ else { const s = Math.sign(a.y - b.y) || 1; a.y += s * gapY / 2; b.y -= s * gapY / 2; }
377
+ }
378
+ }
379
+ const blink = (now) => reduceMotion || Math.sin(now / 140) > -0.3;
380
+ function heatOf(id, now) { const p = pulses.get(id); return p && p > now ? Math.min(1, (p - now) / LINGER_FADE) : 0; }
381
+ function drawBlock(b, now) {
382
+ const heat = Math.max(0, ...b.members.map((n) => heatOf(n.id, now))), colour = colourOf(b.d);
383
+ ctx.beginPath(); ctx.roundRect(b.x - b.w / 2, b.y - b.hgt / 2, b.w, b.hgt, 8);
384
+ ctx.globalAlpha = 0.18 + 0.5 * heat; ctx.fillStyle = heat ? css("--series1") : colour; ctx.fill();
385
+ ctx.globalAlpha = 1; ctx.lineWidth = heat ? 2 : 1.2; ctx.strokeStyle = heat ? css("--series1") : colour; ctx.stroke();
386
+ ctx.font = "600 12px system-ui, sans-serif"; ctx.fillStyle = css("--ink"); ctx.textAlign = "center";
387
+ ctx.globalAlpha = heat && !blink(now) ? 0.25 : 1; ctx.fillText(b.name, b.x - 8, b.y + 4); ctx.globalAlpha = 1; ctx.textAlign = "start";
388
+ ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = css("--ink2"); ctx.fillText(String(b.count), b.x + b.w / 2 - 16, b.y + 4);
389
+ free(b.x - b.w / 2, b.y - b.hgt / 2, b.w, b.hgt);
390
+ }
391
+ function drawEdges(now) {
392
+ const bundles = new Map();
393
+ for (const e of edges) {
394
+ const a = byId.get(e.from), b = byId.get(e.to); if (!a || !b) continue;
395
+ const ca = blocks.get(keyOf(a)), cb = blocks.get(keyOf(b));
396
+ if (ca && ca === cb) continue;
397
+ if (!ca && !cb) { strokeEdge(anchor(a), anchor(b), a === b, e.lit, 1, colourOf(a.domain), now, focusId && e.from !== focusId && e.to !== focusId); continue; }
398
+ if (e.kind === "adapter" && cb) continue;
399
+ const key = (ca ? ca.k : a.id) + "|" + (cb ? cb.k : b.id), held = bundles.get(key) || { from: anchor(a), to: anchor(b), count: 0, lit: null, colour: colourOf(a.domain) };
400
+ held.count++; if (e.lit && e.lit.until > now && (!held.lit || e.lit.until > held.lit.until)) held.lit = e.lit;
401
+ bundles.set(key, held);
402
+ }
403
+ for (const b of bundles.values()) {
404
+ strokeEdge(b.from, b.to, false, b.lit, 1 + Math.log2(b.count), b.colour, now);
405
+ if (b.count > 1) { const mx = (b.from.x + b.to.x) / 2, my = (b.from.y + b.to.y) / 2; ctx.font = "10px system-ui, sans-serif";
406
+ if (free(mx - 8, my - 8, 16, 14)) { ctx.fillStyle = css("--ink2"); ctx.textAlign = "center"; ctx.fillText(String(b.count), mx, my + 3); ctx.textAlign = "start"; } }
407
+ }
408
+ }
409
+ function strokeEdge(from, to, own, litAt, width, colour, now, dim) {
410
+ const lit = litAt && litAt.until > now ? litAt : null, heat = lit ? Math.min(1, (lit.until - now) / LINGER_FADE) : 0;
411
+ ctx.globalAlpha = (dim ? 0.08 : 0.35) + 0.65 * heat; ctx.lineWidth = width + 1.5 * heat;
412
+ ctx.strokeStyle = lit ? css(lit.token) : (showBounds ? colour : css("--axis"));
413
+ ctx.beginPath();
414
+ if (own) ctx.arc(from.x + 9, from.y - 9, 7, 0, 6.283); else { ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); }
415
+ ctx.stroke(); ctx.globalAlpha = 1;
416
+ }
417
+ function draw(now) {
418
+ const r = canvas.getBoundingClientRect(); ctx.clearRect(0, 0, r.width, r.height); placed = []; captions = [];
419
+ blocks = new Map(); for (const k of contexts) if (!open(k)) { const b = blockOf(k); if (b) blocks.set(k, b); }
420
+ scoot();
421
+ separate([...blocks.values()]);
422
+ if (showBounds) drawBounds(now);
423
+ drawEdges(now);
424
+ for (const b of blocks.values()) drawBlock(b, now);
425
+ for (const n of nodes) if (!blocks.has(keyOf(n))) drawNode(n, now);
426
+ drawParticles(now);
427
+ const named = view.s >= fitScale * 2.2;
428
+ for (const n of nodes) if (!blocks.has(keyOf(n)) && !(pulses.get(n.id) > now) && (named || scooted(n))) label(n, false, true);
429
+ if (hover && !blocks.has(keyOf(hover))) label(hover, true);
430
+ requestAnimationFrame(draw);
431
+ }
432
+ function drawNode(n, now) {
433
+ const x = sx(n), y = sy(n), r = n.kind === "aggregate" ? 6 : 7, pulse = pulses.get(n.id);
434
+ const live = pulse && pulse > now, heat = live ? Math.min(1, (pulse - now) / LINGER_FADE) : 0;
435
+ shape(n, x, y, r + (live && !reduceMotion ? 3 * heat : 0));
436
+ ctx.fillStyle = live ? css("--series1") : css("--surface"); ctx.globalAlpha = live ? 0.4 + 0.6 * heat : 1; ctx.fill(); ctx.globalAlpha = 1;
437
+ ctx.lineWidth = live ? 2 : 1.2; ctx.strokeStyle = live ? css("--series1") : (showBounds ? colourOf(n.domain) : css("--ink2")); ctx.stroke();
438
+ if (live && heat > 0.6) label(n, false);
439
+ }
440
+ function label(n, boxed, quiet) {
441
+ const x = sx(n) + 10, y = sy(n) - 8, text = quiet ? n.label : n.label + (n.domain ? " " + keyOf(n) : "");
442
+ ctx.font = (quiet ? "11px" : "12px") + " system-ui, sans-serif";
443
+ const w = ctx.measureText(text).width + 8;
444
+ if (!boxed && !free(x - 4, y - 12, w, 18)) return;
445
+ if (boxed) { ctx.fillStyle = css("--surface"); ctx.fillRect(x - 4, y - 12, w, 18); ctx.strokeStyle = css("--axis"); ctx.strokeRect(x - 4, y - 12, w, 18); }
446
+ ctx.fillStyle = quiet ? css("--ink2") : css("--ink"); ctx.fillText(text, x, y);
447
+ }
448
+ const OUTCOME = { ok: ["--good", "✓ delivered"], wait: ["--warning", "▲ waiting"], bad: ["--critical", "✕ refused"] };
449
+ function drawParticles(now) {
450
+ for (let i = particles.length - 1; i >= 0; i--) {
451
+ const p = particles[i], t = (now - p.start) / p.ms;
452
+ if (t >= 1) { pulses.set(p.to.id, now + LINGER); particles.splice(i, 1); continue; }
453
+ if (t < 0) continue;
454
+ const from = anchor(p.from), to = anchor(p.to), own = from.x === to.x && from.y === to.y, angle = t * 6.283 + 1.57;
455
+ const x = own ? from.x + 9 + Math.cos(angle) * 7 : from.x + (to.x - from.x) * t;
456
+ const y = own ? from.y - 9 + Math.sin(angle) * 7 : from.y + (to.y - from.y) * t, [token, word] = OUTCOME[p.kind];
457
+ ctx.beginPath(); ctx.arc(x, y, 5, 0, 6.283); ctx.fillStyle = css(token); ctx.fill(); ctx.strokeStyle = css("--surface"); ctx.lineWidth = 2; ctx.stroke();
458
+ ctx.font = "11px system-ui, sans-serif"; const text = word.slice(0, 1) + " " + p.text;
459
+ if (free(x + 8, y - 16, ctx.measureText(text).width + 4, 14)) { ctx.fillStyle = css("--ink"); ctx.fillText(text, x + 8, y - 6); }
460
+ }
461
+ }
462
+
463
+ /* ---- messages ---- */
464
+ function outcomeKind(text) { return /deliver/.test(text) ? "ok" : /refus|fail/.test(text) ? "bad" : "wait"; }
465
+ function fire(ev, animate) {
466
+ const now = performance.now(), node = byId.get(ev.aggregate);
467
+ if (node && animate) pulses.set(node.id, now + LINGER);
468
+ for (const r of ev.reactions || []) {
469
+ const bare = String(r.policy).split("::").pop(), kind = outcomeKind(r.outcome || "");
470
+ const token = ev.key + "|" + bare + "|" + kind;
471
+ if (seen.has(token)) continue; seen.set(token, true);
472
+ for (const e of edges.filter((x) => x.label === bare)) {
473
+ const a = byId.get(e.from), b = byId.get(e.to);
474
+ if (a && b && animate) { particles.push({ from: a, to: b, kind, text: ev.name, start: now + 150, ms: reduceMotion ? 1 : 1400 }); e.lit = { until: now + LINGER, token: OUTCOME[kind][0] }; }
475
+ }
476
+ logLine(ev, r, kind);
477
+ }
478
+ if (!(ev.reactions || []).length) logLine(ev, null, null);
479
+ }
480
+ function logLine(ev, reaction, kind) {
481
+ const li = h("li", {}, h("time", {}, clock(ev.at)), (ev.aggregate || "") + " ", h("b", {}, ev.name));
482
+ if (reaction) li.append(" → " + String(reaction.policy).split("::").pop() + (reaction.command ? " → " + reaction.command : ""),
483
+ h("span", { class: "mark", style: "color:var(" + OUTCOME[kind][0] + ")" }, OUTCOME[kind][1]));
484
+ const log = $("log"); log.prepend(li); while (log.children.length > 60) log.lastChild.remove();
485
+ }
486
+ let backlog = [], liveStarted = false;
487
+ function listen() {
488
+ const es = new EventSource("/api/events");
489
+ es.onopen = () => { $("live").textContent = "stream: live"; };
490
+ es.onerror = () => { $("live").textContent = "stream: reconnecting"; };
491
+ es.addEventListener("board", (m) => {
492
+ const ev = JSON.parse(m.data); if (!ev.key) return;
493
+ remember(ev);
494
+ if (!liveStarted) { backlog.push(ev); clearTimeout(listen.t); listen.t = setTimeout(startLive, 600); } else fire(ev, true);
495
+ });
496
+ }
497
+ function startLive() { liveStarted = true; replay(backlog.slice(-12), true); }
498
+ function replay(list, first) {
499
+ if (!first) seen.clear();
500
+ list.forEach((ev, i) => setTimeout(() => fire(ev, true), i * 450));
501
+ }
502
+
503
+ /* ---- table view, hover, boot ---- */
504
+ function renderTable() {
505
+ $("edgetable").replaceChildren(h("table", {}, h("thead", {}, h("tr", {}, ["From", "Policy", "To", "Kind"].map((t) => h("th", {}, t)))),
506
+ h("tbody", {}, edges.map((e) => h("tr", {}, [e.from, e.label || "(bound adapter)", e.to, e.kind].map((t) => h("td", {}, t)))))));
507
+ }
508
+ canvas.addEventListener("mousemove", (e) => {
509
+ const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
510
+ cursor = drag ? null : { x: mx, y: my };
511
+ hover = nodes.find((n) => !blocks.has(keyOf(n)) && Math.hypot(sx(n) - mx, sy(n) - my) < 10) || null;
512
+ const onBlock = [...blocks.values()].some((b) => Math.abs(mx - b.x) < b.w / 2 && Math.abs(my - b.y) < b.hgt / 2);
513
+ canvas.style.cursor = drag ? "grabbing" : hover || onBlock ? "pointer" : "grab";
514
+ });
515
+ $("all").addEventListener("click", () => { allOpen = !allOpen; if (!allOpen) expanded.clear(); $("all").textContent = allOpen ? "Collapse all" : "Expand all"; });
516
+ $("fit").addEventListener("click", fit);
517
+ $("zin").addEventListener("click", () => zoomCentre(1.4));
518
+ $("zout").addEventListener("click", () => zoomCentre(1 / 1.4));
519
+ canvas.addEventListener("mouseleave", () => { hover = null; cursor = null; });
520
+ $("toggle").addEventListener("click", () => { const on = document.body.classList.toggle("tables-only"); $("toggle").setAttribute("aria-pressed", String(on)); $("toggle").textContent = on ? "Show diagram" : "Show table"; });
521
+ $("replay").addEventListener("click", () => replay(backlog.slice(-12), false));
522
+ $("bounds").addEventListener("click", () => { showBounds = !showBounds; $("bounds").setAttribute("aria-pressed", String(showBounds)); $("bounds").textContent = showBounds ? "Hide boundaries" : "Show boundaries"; });
523
+ addEventListener("resize", () => { if (touched) size(); else fit(); });
524
+
525
+ fetch("/api/wiring", { cache: "no-store" }).then((r) => r.json()).then((g) => {
526
+ if (g.unknown) { $("count").textContent = "unknown: " + g.unknown; return; }
527
+ nodes = g.nodes; edges = g.edges; byId = new Map(nodes.map((n) => [n.id, n]));
528
+ assignDomains();
529
+ $("count").textContent = domains.length + " domains, " + contexts.length + " contexts, " + nodes.filter((n) => n.kind === "aggregate").length + " aggregates, " + edges.filter((e) => e.kind !== "adapter").length + " policies";
530
+ $("domains").replaceChildren(...domains.map((d) => h("span", {}, h("i", { class: "swatch", style: "background:" + colourOf(d) }), d)));
531
+ layout(); fit(); renderTable(); requestAnimationFrame(draw); listen();
532
+ const wanted = decodeURIComponent(location.hash.slice(1)); if (wanted && byId.has(wanted)) { expanded.add(keyOf(byId.get(wanted))); openPanel(byId.get(wanted)); }
533
+ }).catch((e) => { $("count").textContent = "unknown: " + e.message; });
534
+ </script>
535
+ </body>
536
+ </html>
@@ -0,0 +1,7 @@
1
+ # Binds the `Web` port to `PipelineWeb`, which serves the board on 127.0.0.1 until it is stopped.
2
+ # Required by name, not relative, so a copy of this directory shares the one implementation loaded.
3
+ require "hecks/pipeline_board/adapters/pipeline_web"
4
+
5
+ Hecks.adapter "PipelineWeb" do
6
+ port "Web"
7
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_stages"
4
+ require_relative "pipeline_wiring"
5
+ require_relative "pipeline_web/server"
6
+ require_relative "pipeline_web/stream"
7
+ require_relative "pipeline_web/snapshot"
8
+
9
+ module Hecks
10
+ module Adapters
11
+ # The `Web` port's adapter: opens the board on 127.0.0.1 and answers `GET /`, `GET /api/state`
12
+ # and the `GET /api/events` stream until it is stopped. The server is the only effect the board
13
+ # has; every answer is read from {PipelineStages} and nothing is written.
14
+ class PipelineWeb
15
+ PAGE = File.expand_path("pipeline_web/page.html", __dir__)
16
+ WIRING_PAGE = File.expand_path("pipeline_web/wiring.html", __dir__)
17
+ SEQUENCE_PAGE = File.expand_path("pipeline_web/sequence.html", __dir__)
18
+ DEFAULT_PORT = 4747
19
+
20
+ # @param registry [Runtime::Registry, nil] handed to the readers
21
+ # @param aggregate [Object, nil] unused
22
+ # @param settings [Hash] `stages:` stands in for {PipelineStages}, `interval:` is the seconds
23
+ # between looks at the journal on a stream
24
+ # @param root [String, nil] the checkout the readers run in
25
+ def initialize(registry: nil, aggregate: nil, settings: {}, root: nil)
26
+ @stages = settings[:stages] || PipelineStages.new(registry: registry, root: root)
27
+ @wiring = settings[:wiring] || PipelineWiring.new(registry: registry)
28
+ @interval = settings.fetch(:interval, 1.0)
29
+ @snapshot = PipelineBoard::Snapshot.new(source: @stages.method(:state), background: settings.fetch(:background, true))
30
+ end
31
+
32
+ # Serves the board until interrupted.
33
+ #
34
+ # @param held [Hash] the `PipelineBoard` record: `port`
35
+ # @return [Hash{Symbol => Hash}] `last:` why the server ended
36
+ def listen(**held)
37
+ port = held[:port].is_a?(Hash) ? held[:port][:value] : held[:port]
38
+ server = open_at(port)
39
+ warn "PipelineBoard listening on http://#{server.address}:#{server.port}/ (Ctrl-C to stop)"
40
+ trap("INT") { server.stop }
41
+ server.join
42
+ { last: { value: "stopped" } }
43
+ end
44
+
45
+ # @param port [Integer, nil] 0 binds a free port; nil the board's default
46
+ # @return [PipelineBoard::Server] a running server
47
+ def open_at(port)
48
+ @snapshot.start
49
+ PipelineBoard::Server.new(port: port || DEFAULT_PORT, app: self).start
50
+ end
51
+
52
+ # @return [String] the page
53
+ def page = File.read(PAGE)
54
+
55
+ # @return [Hash] every section the page shows
56
+ def state = @snapshot.latest
57
+
58
+ # @return [String] the wiring diagram's page
59
+ def wiring_page = File.read(WIRING_PAGE)
60
+
61
+ # @return [String] the sequence diagram's page
62
+ def sequence_page = File.read(SEQUENCE_PAGE)
63
+
64
+ # @return [Hash] the wiring graph (`nodes` and `edges`), or `{unknown: reason}`
65
+ def wiring
66
+ @wiring.graph
67
+ rescue PipelineBoard::Unavailable => e
68
+ { unknown: e.message }
69
+ end
70
+
71
+ # Yields each event of the journal once and again when its reactions change, until the reader
72
+ # goes away.
73
+ #
74
+ # @yieldparam event [Hash] an event row, an `unknown` note or a heartbeat
75
+ def stream(&)
76
+ PipelineBoard::Stream.new(feed: @stages.method(:feed), interval: @interval).each(&)
77
+ end
78
+ end
79
+ end
80
+ end