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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # What every tile builder shares: the tile's shape, its trend against the earlier half of a
9
+ # window, and the small statistics.
10
+ module TileKit
11
+ # @return [Hash] a tile with its number, unit, state word, trend and detail line
12
+ def tile(id, label, value, unit, extra = {})
13
+ state = value.nil? ? "unknown" : "ok"
14
+ { id: id, label: label, value: value, unit: unit, state: state, detail: "", trend: nil }.merge(extra)
15
+ end
16
+
17
+ # @return [Hash] a tile for a section that could not be read, saying why
18
+ def unknown_tile(id, label, section)
19
+ reason = section.is_a?(Hash) ? section[:unknown] : "nothing to read"
20
+ tile(id, label, nil, "", detail: "unknown: #{reason}")
21
+ end
22
+
23
+ # @return [Hash] the change between two numbers; `better` is true when it improved
24
+ def trend(before, after, unit, lower_is_better: false)
25
+ return nil if before.nil? || after.nil?
26
+
27
+ delta = (after - before).round(1)
28
+ { delta: delta, unit: unit, better: delta.zero? ? nil : (delta.negative? == lower_is_better) }
29
+ end
30
+
31
+ # @return [Array<Array>] the earlier and the later half of a list
32
+ def halves(list)
33
+ cut = list.size / 2
34
+ [list.first(cut), list.drop(cut)]
35
+ end
36
+
37
+ # @return [Float, nil] the middle of the numbers, nil when there are none
38
+ def median(list)
39
+ sorted = list.compact.sort
40
+ return nil if sorted.empty?
41
+
42
+ ((sorted[(sorted.size - 1) / 2] + sorted[sorted.size / 2]) / 2.0).round(1)
43
+ end
44
+
45
+ # @return [Integer, nil] the share of runs that succeeded, as a whole percent
46
+ def success_rate(list)
47
+ return nil if list.empty?
48
+
49
+ (100.0 * list.count { |run| run[:conclusion] == "success" } / list.size).round
50
+ end
51
+
52
+ # @return [Time] when a run ended: its start plus its wall time
53
+ def finished(run) = Time.parse(run[:started]) + (run[:wall_minutes].to_f * 60)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tile_kit"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # The tiles that read the work: commits not yet pushed, sessions against the cap and the load
9
+ # against its limit, rows finished in a day, and the latest release.
10
+ module WorkTiles
11
+ include TileKit
12
+
13
+ NUDGE_COMMITS = 15
14
+ NUDGE_HOURS = 6
15
+ DEFAULT_CAP = 8
16
+ DAY = 86_400
17
+
18
+ def ahead(local, now)
19
+ return unknown_tile("ahead", "Commits not pushed", local) unless readable?(local)
20
+
21
+ age = hours_since(local[:oldest_unpushed], now)
22
+ over = local[:ahead] >= NUDGE_COMMITS || age >= NUDGE_HOURS
23
+ note = "oldest #{age} h; nudge at #{NUDGE_COMMITS} commits or #{NUDGE_HOURS} h"
24
+ tile("ahead", "Commits not pushed", local[:ahead], "commits", state: over ? "attention" : "ok", detail: note)
25
+ end
26
+
27
+ def working(data)
28
+ return unknown_tile("working", "Sessions busy", data[:sessions]) unless data[:sessions].is_a?(Array)
29
+
30
+ busy = data[:sessions].count { |session| session[:status] == "busy" }
31
+ fill = readable?(data[:fill]) ? data[:fill] : {}
32
+ cap = fill[:cap] || DEFAULT_CAP
33
+ tile("working", "Sessions busy", busy, "of #{cap}", state: busy >= cap ? "attention" : "ok", detail: load_note(fill))
34
+ end
35
+
36
+ def throughput(history, now)
37
+ return unknown_tile("throughput", "Work finished", history) unless history.is_a?(Array)
38
+
39
+ done = history.count { |event| event[:name] == "WorkFinished" && now - Time.parse(event[:at]) < DAY }
40
+ tile("throughput", "Work finished", done, "items / 24 h", detail: "Work.WorkFinished events in the journal")
41
+ end
42
+
43
+ def release(release, now)
44
+ return unknown_tile("release", "Latest release", release) unless readable?(release)
45
+
46
+ days = release[:published_at] && ((now - Time.parse(release[:published_at])) / DAY).floor
47
+ word = release[:published] ? "published" : "draft"
48
+ tile("release", "Latest release", days, "days ago", detail: "#{release[:tag]} #{word}")
49
+ end
50
+
51
+ def readable?(section) = section.is_a?(Hash) && !section.key?(:unknown)
52
+
53
+ def hours_since(stamp, now) = stamp ? ((now - Time.parse(stamp)) / 3600.0).round(1) : 0.0
54
+
55
+ def load_note(fill) = fill[:load] ? "load #{fill[:load]} of #{fill[:limit]}" : "load unknown"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ # Binds the `Pipeline` port to `PipelineStages`, which answers `Stages` from git, GitHub, Tickets, Jobs and the journal.
2
+ # Required by name, not relative, so a copy of this directory shares the one implementation loaded.
3
+ require "hecks/pipeline_board/adapters/pipeline_stages"
4
+
5
+ Hecks.adapter "PipelineStages" do
6
+ port "Pipeline"
7
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "probe"
6
+ require_relative "pipeline_github"
7
+ require_relative "pipeline_git"
8
+ require_relative "pipeline_tickets"
9
+ require_relative "pipeline_jobs"
10
+ require_relative "pipeline_journal"
11
+ require_relative "pipeline_fill"
12
+ require_relative "pipeline_stages/gather"
13
+ require_relative "pipeline_stages/strip"
14
+ require_relative "pipeline_stages/metrics"
15
+ require_relative "pipeline_stages/fanout"
16
+
17
+ module Hecks
18
+ module Adapters
19
+ # The `Pipeline` port's adapter for `Stages`, and the one the page reads from: it asks each
20
+ # reader (GitHub, Git, Tickets, Jobs, Journal, and the Dispatch.Fill dry run) its question,
21
+ # turns the answers into the pipeline strip, the metric tiles and the fan-out, and gives
22
+ # every section that failed as `{unknown: reason}`. It only reads.
23
+ class PipelineStages
24
+ include PipelineBoard::Gather
25
+
26
+ # @param registry [Runtime::Registry, nil] the booted registry the Tickets reader uses
27
+ # @param aggregate [Object, nil] unused
28
+ # @param settings [Hash] `readers:` replaces any of `github`, `git`, `tickets`, `jobs`,
29
+ # `journal` and `fill`; `now:` is a callable answering the time
30
+ # @param root [String, nil] the checkout the readers run in
31
+ def initialize(registry: nil, aggregate: nil, settings: {}, root: nil)
32
+ @root = root || Dir.pwd
33
+ @now = settings[:now] || -> { Time.now.utc }
34
+ wire(settings.fetch(:readers, {}), registry)
35
+ end
36
+
37
+ # @return [Hash{Symbol => String}] `text:` one row per stage as JSON
38
+ def stages = { text: JSON.generate(PipelineBoard::Strip.build(sections, @now.call)) }
39
+
40
+ # @return [Hash] every section the page shows: `stages`, `metrics`, the four tables, the
41
+ # fan-out and the events, each an answer or `{unknown: reason}`
42
+ def state
43
+ data = sections
44
+ now = @now.call
45
+ { generated_at: now.iso8601, stages: PipelineBoard::Strip.build(data, now),
46
+ metrics: PipelineBoard::Metrics.tiles(data, now), ci_runs: data[:ci_runs], slowest_jobs: data[:slowest_jobs],
47
+ open_work: data[:open_work], sessions: data[:sessions], events: data[:events], fanout: fanout(data) }
48
+ end
49
+
50
+ # @return [Array<Hash>, Hash] the newest events with the policies that reacted, or
51
+ # `{unknown: reason}`
52
+ def feed = guard { @journal.recent(40) }
53
+
54
+ private
55
+
56
+ def wire(readers, registry)
57
+ @github = readers.fetch(:github) { PipelineGitHub.new(root: @root) }
58
+ @git = readers.fetch(:git) { PipelineGit.new(root: @root) }
59
+ @journal = readers.fetch(:journal) { PipelineJournal.new(root: @root) }
60
+ @fill = readers.fetch(:fill) { PipelineFill.new(root: @root) }
61
+ wire_work(readers, registry)
62
+ end
63
+
64
+ def wire_work(readers, registry)
65
+ @tickets = readers.fetch(:tickets) { PipelineTickets.new(registry: registry) }
66
+ @jobs = readers.fetch(:jobs) { PipelineJobs.new(settings: { runs: -> { guard { @tickets.runs } } }) }
67
+ end
68
+
69
+ def sections
70
+ data = gathered
71
+ data.merge(dated(data))
72
+ end
73
+
74
+ def fanout(data)
75
+ return data[:sessions] unless data[:sessions].is_a?(Array)
76
+
77
+ landed = data[:landed].is_a?(Array) ? data[:landed] : []
78
+ work = data[:open_work].is_a?(Array) ? data[:open_work] : []
79
+ mapped = PipelineBoard::Fanout.mapping(sessions: data[:sessions], work: work, landed: landed, fill: data[:fill])
80
+ { rows: mapped, flags: PipelineBoard::Fanout.flags_of(mapped), fill: data[:fill], sessions: data[:sessions] }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,7 @@
1
+ # Binds the `Tickets` port to `PipelineTickets`, which answers `OpenWork` with the Work items that are not done.
2
+ # Required by name, not relative, so a copy of this directory shares the one implementation loaded.
3
+ require "hecks/pipeline_board/adapters/pipeline_tickets"
4
+
5
+ Hecks.adapter "PipelineTickets" do
6
+ port "Tickets"
7
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "probe"
5
+ require_relative "../../hecks/adapters/started_work"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ # The `Tickets` port's adapter: answers `OpenWork` with the Work items that are not done, read
10
+ # through the booted Tickets domain's repository (never from its database), and reports which
11
+ # session ran each row from the AgentRun records. It only reads.
12
+ class PipelineTickets
13
+ DONE = %w[done dropped].freeze
14
+ ROW_LINE = /^ROW:\s*(\S+)/
15
+ EMPTY = "the Tickets store holds no Work items here (Memory); run the board from the main checkout"
16
+
17
+ # @param registry [Runtime::Registry, nil] the booted registry whose rows are read
18
+ # @param aggregate [Object, nil] unused
19
+ # @param settings [Hash] `rows:` and `landings:` stand in for the registry's Work and
20
+ # Landing records
21
+ # @param root [String, nil] unused
22
+ def initialize(registry: nil, aggregate: nil, settings: {}, root: nil)
23
+ @registry = registry
24
+ @standin = settings[:rows]
25
+ @landed = settings[:landings]
26
+ end
27
+
28
+ # @return [Hash{Symbol => String}] `text:` the open rows as JSON: slug, status, area, and
29
+ # who claimed each and when
30
+ def open_work = { text: JSON.generate(rows.reject { |row| DONE.include?(row[:status]) }) }
31
+
32
+ # @return [Hash{String => Integer}] every Work row counted by status
33
+ def counts = rows.map { |row| row[:status] }.tally
34
+
35
+ # @return [Hash{String => Hash}] each AgentRun's session id with its `row` (named on the
36
+ # first line of its brief) and `status`
37
+ def runs
38
+ records_of("Hecks", "AgentRun").to_h { |run| [field(run, :session).to_s, run_row(run)] }
39
+ end
40
+
41
+ # @return [Array<String>] the branches the Landing records say were merged onto main, with any
42
+ # leading `qa/` taken off
43
+ def landed_branches
44
+ landings = @landed || records_of("Hecks", "Landing")
45
+ landed = landings.select { |land| field(land, :status).to_s == "landed" }
46
+ landed.map { |land| field(land, :branch).to_s.delete_prefix("qa/") }
47
+ end
48
+
49
+ private
50
+
51
+ def rows
52
+ found = @standin || records_of("Tickets", "Work").map { |record| row(record) }
53
+ raise PipelineBoard::Unavailable, EMPTY if found.empty?
54
+
55
+ found
56
+ end
57
+
58
+ def row(record)
59
+ { slug: field(record, :work).to_s, status: field(record, :status).to_s, title: field(record, :title).to_s,
60
+ area: StartedWork.words(field(record, :area)), claimed_by: field(record, :claimed_by).to_s,
61
+ claimed_at: field(record, :claimed_at).to_s }
62
+ end
63
+
64
+ def run_row(run) = { row: field(run, :brief).to_s[ROW_LINE, 1].to_s, status: field(run, :status).to_s }
65
+
66
+ def records_of(domain, name)
67
+ bluebook = @registry&.bluebook(domain)
68
+ aggregate = bluebook&.aggregates&.find { |candidate| candidate.hecks_name == name }
69
+ raise PipelineBoard::Unavailable, "the #{domain} domain is not booted here" unless aggregate
70
+
71
+ @registry.repository(domain, aggregate).all
72
+ end
73
+
74
+ def field(record, name) = StartedWork.value(record, name)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,292 @@
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 Board</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; --series2: #eb6834;
13
+ --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
14
+ --on-fill: #ffffff; --on-warning: #0b0b0b; --good-text: #006300;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root:not([data-theme="light"]) {
18
+ color-scheme: dark;
19
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink2: #c3c2b7; --muted: #898781;
20
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
21
+ --series1: #3987e5; --series2: #d95926; --good-text: #0ca30c;
22
+ }
23
+ }
24
+ :root[data-theme="dark"] {
25
+ color-scheme: dark;
26
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink2: #c3c2b7; --muted: #898781;
27
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
28
+ --series1: #3987e5; --series2: #d95926; --good-text: #0ca30c;
29
+ }
30
+ * { box-sizing: border-box; }
31
+ body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
32
+ main { max-width: 1280px; margin: 0 auto; padding: 16px; }
33
+ header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; margin-bottom: 16px; }
34
+ h1 { font-size: 20px; margin: 0; font-weight: 600; }
35
+ h2 { font-size: 13px; margin: 0 0 8px; color: var(--ink2); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
36
+ .meta { color: var(--ink2); font-size: 13px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
37
+ a { color: var(--series1); }
38
+ button { font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--axis); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
39
+ button:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--series1); outline-offset: 2px; }
40
+ section { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 16px; }
41
+ .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
42
+ .tile { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; min-height: 108px; }
43
+ .tile .label { color: var(--ink2); font-size: 12px; }
44
+ .tile .num { font-size: 30px; line-height: 1.15; font-weight: 600; }
45
+ .tile .unit { font-size: 13px; color: var(--ink2); font-weight: 400; margin-left: 4px; }
46
+ .tile .note { color: var(--muted); font-size: 12px; margin-top: 2px; }
47
+ .tile .trend { font-size: 12px; color: var(--ink2); }
48
+ .strip { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0; }
49
+ .node { position: relative; border: 1px solid var(--border); padding: 10px; margin-right: 14px; border-radius: 8px; min-height: 112px; }
50
+ .node:not(:last-child)::after { content: "\2192"; position: absolute; right: -14px; top: 40%; width: 14px; text-align: center; color: var(--muted); }
51
+ .node .name { font-size: 12px; color: var(--ink2); }
52
+ .node .count { font-size: 22px; font-weight: 600; overflow-wrap: anywhere; }
53
+ .node .detail { font-size: 12px; color: var(--ink2); overflow-wrap: anywhere; }
54
+ .badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; padding: 1px 7px; border-radius: 999px; color: var(--on-fill); }
55
+ .badge.ok { background: var(--good); } .badge.attention { background: var(--warning); color: var(--on-warning); }
56
+ .badge.failed { background: var(--critical); } .badge.working { background: var(--series1); }
57
+ .badge.unknown { background: transparent; color: var(--ink2); border: 1px dashed var(--muted); }
58
+ .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; }
59
+ .cols { display: flex; align-items: flex-end; gap: 4px; height: 140px; border-bottom: 1px solid var(--axis); padding-top: 18px; }
60
+ .col { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; }
61
+ .col .bar { width: 100%; max-width: 34px; background: var(--series1); border-radius: 4px 4px 0 0; min-height: 0; }
62
+ .col .bar.second { background: var(--series2); }
63
+ .col .val { font-size: 11px; color: var(--ink2); }
64
+ .xlabels { display: flex; gap: 4px; }
65
+ .xlabels span { flex: 1; font-size: 10px; color: var(--muted); text-align: center; overflow: hidden; text-overflow: clip; white-space: nowrap; }
66
+ .hbar { display: grid; grid-template-columns: minmax(90px, 36%) 1fr 52px; gap: 8px; align-items: center; margin: 5px 0; font-size: 12px; }
67
+ .hbar .track { height: 12px; }
68
+ .hbar .fill { height: 100%; background: var(--series1); border-radius: 0 4px 4px 0; }
69
+ .hbar .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
70
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
71
+ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--grid); vertical-align: top; }
72
+ th { color: var(--ink2); font-weight: 600; font-size: 12px; }
73
+ td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
74
+ .scroll { overflow-x: auto; }
75
+ .lanes { position: relative; border-left: 1px solid var(--axis); }
76
+ .lane { position: relative; height: 22px; border-bottom: 1px solid var(--grid); }
77
+ .lane .tag { position: absolute; left: 4px; top: 2px; font-size: 11px; color: var(--ink2); z-index: 2; white-space: nowrap; pointer-events: none; }
78
+ .lane .span { position: absolute; top: 3px; height: 16px; border-radius: 4px; min-width: 6px; opacity: .85; }
79
+ .span.busy { background: var(--series1); } .span.done { background: var(--good); } .span.failed { background: var(--critical); }
80
+ .hours { display: flex; justify-content: space-between; font-size: 10px; color: var(--muted); margin-top: 2px; }
81
+ .legend { display: flex; gap: 14px; font-size: 12px; color: var(--ink2); margin: 6px 0; flex-wrap: wrap; }
82
+ .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; }
83
+ .meter { height: 12px; background: var(--grid); border-radius: 6px; overflow: hidden; margin: 4px 0; }
84
+ .meter > div { height: 100%; background: var(--series1); }
85
+ .flag { border-left: 4px solid var(--warning); padding: 4px 8px; margin: 4px 0; background: var(--page); border-radius: 0 6px 6px 0; font-size: 13px; }
86
+ .flag b { font-weight: 600; }
87
+ .feed { max-height: 560px; overflow: auto; }
88
+ .ev { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
89
+ .ev.fresh { animation: flash 2.4s ease-out; }
90
+ @keyframes flash { from { background: color-mix(in srgb, var(--series1) 28%, transparent); } to { background: transparent; } }
91
+ @media (prefers-reduced-motion: reduce) { .ev.fresh { animation: none; border-color: var(--series1); } }
92
+ .ev .head { display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; }
93
+ .ev .time { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
94
+ .ev .name { font-weight: 600; }
95
+ .ev .agg { color: var(--ink2); font-size: 12px; overflow-wrap: anywhere; }
96
+ .chain { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; font-size: 12px; }
97
+ .chain .arrow { color: var(--muted); }
98
+ .chain .step { border: 1px solid var(--border); border-radius: 6px; padding: 2px 7px; overflow-wrap: anywhere; }
99
+ .chain .step.policy { border-style: dashed; }
100
+ .live { font-size: 12px; }
101
+ .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: 280px; }
102
+ .unknown-note { color: var(--ink2); font-style: italic; }
103
+ .tables-only .chartview { display: none; } body:not(.tables-only) .tableview { display: none; }
104
+ @media (max-width: 900px) {
105
+ .strip { grid-template-columns: 1fr; gap: 10px; }
106
+ .node { margin-right: 0; min-height: 0; }
107
+ .node:not(:last-child)::after { display: none; }
108
+ }
109
+ </style>
110
+ </head>
111
+ <body>
112
+ <main>
113
+ <header>
114
+ <h1>Pipeline Board</h1>
115
+ <div class="meta">
116
+ <a href="/wiring">Wiring diagram</a>
117
+ <a href="/sequence">Sequence</a>
118
+ <span id="updated">loading</span>
119
+ <span class="live" id="live">stream: connecting</span>
120
+ <button id="toggle" type="button" aria-pressed="false">Show tables</button>
121
+ </div>
122
+ </header>
123
+
124
+ <section aria-labelledby="h-tiles"><h2 id="h-tiles">Key metrics</h2><div class="tiles" id="tiles"></div></section>
125
+ <section aria-labelledby="h-strip"><h2 id="h-strip">Pipeline</h2><div class="strip" id="strip"></div></section>
126
+
127
+ <section aria-labelledby="h-fan"><h2 id="h-fan">Fan-out</h2>
128
+ <div class="grid2">
129
+ <div><div class="legend" id="lanelegend"></div><div id="lanes" class="chartview"></div><div class="tableview scroll" id="lanetable"></div></div>
130
+ <div><div id="meters"></div><div id="flags"></div><div id="fill"></div></div>
131
+ </div>
132
+ <div class="scroll" id="mapping"></div>
133
+ </section>
134
+
135
+ <section aria-labelledby="h-ci"><h2 id="h-ci">CI on main</h2>
136
+ <div class="grid2">
137
+ <div><div class="label">Failed jobs per run, oldest to newest</div><div id="failedchart" class="chartview"></div><div class="tableview scroll" id="failedtable"></div></div>
138
+ <div><div class="label">Wall minutes per run (goal 3)</div><div id="wallchart" class="chartview"></div><div class="tableview scroll" id="walltable"></div></div>
139
+ </div>
140
+ <h2 style="margin-top:14px">Slowest jobs of the last completed run</h2>
141
+ <div id="slowchart" class="chartview"></div><div class="tableview scroll" id="slowtable"></div>
142
+ </section>
143
+
144
+ <section aria-labelledby="h-work"><h2 id="h-work">Open Work items</h2><div class="scroll" id="work"></div></section>
145
+ <section aria-labelledby="h-sess"><h2 id="h-sess">Sessions</h2><div class="scroll" id="sessions"></div></section>
146
+ <section aria-labelledby="h-ev"><h2 id="h-ev">Events and what answered them</h2><div class="feed" id="feed" aria-live="polite"></div></section>
147
+ </main>
148
+ <div class="tip" id="tip" role="tooltip"></div>
149
+ <script>
150
+ "use strict";
151
+ const ICON = { ok: "✓", working: "◔", attention: "▲", failed: "✕", unknown: "?" };
152
+ const WORD = { ok: "ok", working: "working", attention: "attention", failed: "failed", unknown: "unknown" };
153
+ const $ = (id) => document.getElementById(id);
154
+ const tip = $("tip");
155
+
156
+ function h(tag, attrs, ...kids) {
157
+ const el = document.createElement(tag);
158
+ for (const [k, v] of Object.entries(attrs || {})) {
159
+ if (k === "class") el.className = v; else if (k === "tip") el.dataset.tip = v; else el.setAttribute(k, v);
160
+ }
161
+ for (const kid of kids.flat()) if (kid !== null && kid !== undefined) el.append(kid.nodeType ? kid : String(kid));
162
+ return el;
163
+ }
164
+ function put(id, ...kids) { const el = $(id); el.replaceChildren(...kids.flat()); }
165
+ function badge(state) { return h("span", { class: "badge " + state }, ICON[state] || "?", " ", WORD[state] || state); }
166
+ function isUnknown(x) { return x && !Array.isArray(x) && typeof x === "object" && "unknown" in x; }
167
+ function unknown(x) { return h("p", { class: "unknown-note" }, "unknown: " + x.unknown); }
168
+ function clock(iso) { if (!iso) return "-"; const d = new Date(iso); return isNaN(d) ? String(iso) : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
169
+ function table(head, rows, numeric) {
170
+ return h("table", {}, h("thead", {}, h("tr", {}, head.map((t, i) => h("th", { class: numeric && numeric.includes(i) ? "n" : "" }, t)))),
171
+ h("tbody", {}, rows.map((r) => h("tr", {}, r.map((c, i) => h("td", { class: numeric && numeric.includes(i) ? "n" : "" }, c))))));
172
+ }
173
+
174
+ document.addEventListener("mouseover", (e) => {
175
+ const t = e.target.closest("[data-tip]");
176
+ if (!t) { tip.style.display = "none"; return; }
177
+ tip.textContent = t.dataset.tip; tip.style.display = "block";
178
+ tip.style.left = Math.min(e.clientX + 12, innerWidth - 290) + "px"; tip.style.top = (e.clientY + 14) + "px";
179
+ });
180
+
181
+ function renderTiles(tiles) {
182
+ put("tiles", tiles.map((t) => {
183
+ const value = t.value === null || t.value === undefined ? "-" : t.value;
184
+ const tr = t.trend ? h("div", { class: "trend" }, (t.trend.delta > 0 ? "▲ +" : t.trend.delta < 0 ? "▼ " : "= ") + t.trend.delta + " " + t.trend.unit,
185
+ t.trend.better === null ? " flat" : t.trend.better ? " better" : " worse", " vs earlier half") : null;
186
+ return h("div", { class: "tile", tip: t.label + ": " + t.detail },
187
+ h("div", { class: "label" }, t.label), h("div", { class: "num" }, String(value), h("span", { class: "unit" }, t.unit)),
188
+ badge(t.state), tr, h("div", { class: "note" }, t.detail));
189
+ }));
190
+ }
191
+ function renderStrip(stages) {
192
+ put("strip", stages.map((s) => h("div", { class: "node", tip: s.name + ": " + s.detail },
193
+ h("div", { class: "name" }, s.name), h("div", { class: "count" }, s.count), badge(s.state), h("div", { class: "detail" }, s.detail))));
194
+ }
195
+ function columns(rows, pick, label, second) {
196
+ const max = Math.max(1, ...rows.map(pick));
197
+ const cols = rows.map((r) => h("div", { class: "col", tip: r.sha + " " + (r.conclusion || r.status) + ": " + label(r) },
198
+ h("div", { class: "val" }, String(pick(r))), h("div", { class: "bar" + (second ? " second" : ""), style: "height:" + Math.round(100 * pick(r) / max) + "%" })));
199
+ return [h("div", { class: "cols" }, cols), h("div", { class: "xlabels" }, rows.map((r) => h("span", {}, r.sha.slice(0, 4))))];
200
+ }
201
+ function renderCi(runs, slow) {
202
+ if (isUnknown(runs)) { for (const id of ["failedchart", "wallchart", "failedtable", "walltable"]) put(id, unknown(runs)); }
203
+ else {
204
+ put("failedchart", columns(runs, (r) => r.failed_jobs || 0, (r) => (r.failed_jobs === null ? "failed jobs unknown" : r.failed_jobs + " failed jobs")));
205
+ put("wallchart", columns(runs, (r) => r.wall_minutes, (r) => r.wall_minutes + " min", true));
206
+ put("failedtable", table(["Run", "Started", "Conclusion", "Failed jobs"], runs.map((r) => [r.sha, clock(r.started), r.conclusion || r.status, r.failed_jobs === null ? "?" : r.failed_jobs]), [3]));
207
+ put("walltable", table(["Run", "Wall minutes"], runs.map((r) => [r.sha, r.wall_minutes]), [1]));
208
+ }
209
+ if (isUnknown(slow)) { put("slowchart", unknown(slow)); put("slowtable", unknown(slow)); return; }
210
+ const max = Math.max(1, ...slow.map((j) => j.minutes));
211
+ put("slowchart", slow.map((j) => h("div", { class: "hbar", tip: j.name + ": " + j.minutes + " min (" + j.conclusion + ")" },
212
+ h("div", { class: "name" }, j.name), h("div", { class: "track" }, h("div", { class: "fill", style: "width:" + Math.round(100 * j.minutes / max) + "%" })), h("div", {}, j.minutes + " min"))));
213
+ put("slowtable", table(["Job", "Minutes", "Conclusion"], slow.map((j) => [j.name, j.minutes, j.conclusion]), [1]));
214
+ }
215
+ function renderWork(work) {
216
+ if (isUnknown(work)) return put("work", unknown(work));
217
+ put("work", table(["Work item", "Status", "Area", "Claimed by", "Claimed at"], work.map((w) => [w.slug, w.status, (w.area || []).join(", ") || "-", w.claimed_by || "(nobody)", w.claimed_at || "-"])));
218
+ }
219
+ function renderSessions(list) {
220
+ if (isUnknown(list)) return put("sessions", unknown(list));
221
+ put("sessions", table(["Session", "Status", "Work item", "Started", "Detail"], list.map((s) => [s.name, [badge(s.status === "busy" ? "working" : s.status === "failed" ? "failed" : "ok"), " ", s.state], s.row || "-", clock(s.started), s.detail])));
222
+ }
223
+ function renderFan(fan) {
224
+ if (isUnknown(fan)) { for (const id of ["lanes", "meters", "flags", "fill", "mapping", "lanetable"]) put(id, unknown(fan)); return; }
225
+ const sessions = fan.sessions.filter((s) => s.started), now = Date.now(), span = 24 * 3600 * 1000, start = now - span;
226
+ put("lanelegend", ["busy", "done", "failed"].map((k) => h("span", {}, h("i", { class: "swatch span " + k }), (k === "busy" ? "◔ busy" : k === "done" ? "✓ done" : "✕ failed"))));
227
+ put("lanes", h("div", { class: "lanes" }, sessions.map((s) => {
228
+ const a = Math.max(start, Date.parse(s.started)), b = s.ended ? Date.parse(s.ended) : now;
229
+ const left = 100 * (a - start) / span, width = Math.max(0.5, 100 * (Math.min(b, now) - a) / span);
230
+ return h("div", { class: "lane" }, h("div", { class: "tag" }, (s.status === "busy" ? "◔ " : s.status === "failed" ? "✕ " : "✓ ") + s.name + (s.row ? " · " + s.row : "")),
231
+ h("div", { class: "span " + s.status, style: "left:" + left + "%;width:" + width + "%", tip: s.name + " " + s.status + " " + clock(s.started) + " to " + (s.ended ? clock(s.ended) : "now") }));
232
+ })), h("div", { class: "hours" }, h("span", {}, "-24 h"), h("span", {}, "-12 h"), h("span", {}, "now")));
233
+ put("lanetable", table(["Session", "Status", "Started", "Ended", "Work item"], sessions.map((s) => [s.name, s.status, clock(s.started), s.ended ? clock(s.ended) : "now", s.row || "-"])));
234
+ const fill = fan.fill, ok = fill && !isUnknown(fill), busy = sessions.filter((s) => s.status === "busy").length, cap = ok ? fill.cap : 8;
235
+ put("meters", h("div", {}, h("div", {}, "Sessions busy: " + busy + " of " + cap), h("div", { class: "meter" }, h("div", { style: "width:" + Math.min(100, 100 * busy / cap) + "%" })),
236
+ ok ? [h("div", {}, "Machine load: " + fill.load + " of " + fill.limit), h("div", { class: "meter" }, h("div", { style: "width:" + Math.min(100, 100 * fill.load / fill.limit) + "%" }))] : h("div", { class: "unknown-note" }, "load: unknown")));
237
+ put("flags", fan.flags.length ? fan.flags.map((f) => h("div", { class: "flag" }, badge("attention"), " ", h("b", {}, f.kind), " ", f.row + ": " + f.detail)) : h("div", {}, badge("ok"), " no disagreements between work items and sessions"));
238
+ put("fill", ok ? [h("h2", { style: "margin-top:10px" }, "Dispatch.Fill dry run: would start " + fill.would_start.length),
239
+ table(["Work item", "Result"], fill.would_start.map((w) => [w.slug, "would start"]).concat(fill.skipped.map((w) => [w.slug, "skipped: " + w.reason])))] : unknown(fill || { unknown: "none" }));
240
+ put("mapping", table(["Work item", "Status", "Claimed by", "Claimed at", "Sessions", "Flags"], fan.rows.map((r) => [r.slug, r.status, r.claimed_by || "(nobody)", r.claimed_at || "-", r.sessions.join(", ") || "-", r.flags.map((f) => f.kind).join(", ") || "-"])));
241
+ }
242
+
243
+ const seen = new Map();
244
+ function chainOf(ev) {
245
+ const nodes = [h("span", { class: "step" }, ev.name)];
246
+ for (const r of ev.reactions || []) {
247
+ const state = /deliver|settled|done/.test(r.outcome) ? "ok" : /refus|fail/.test(r.outcome) ? "failed" : "attention";
248
+ nodes.push(h("span", { class: "arrow" }, "→"), h("span", { class: "step policy", tip: "policy " + r.policy }, r.policy), badge(state), " " + (r.outcome || "no record"));
249
+ if (r.command) nodes.push(h("span", { class: "arrow" }, "→"), h("span", { class: "step" }, r.command));
250
+ for (const e of r.emitted || []) nodes.push(h("span", { class: "arrow" }, "→"), h("span", { class: "step" }, e));
251
+ }
252
+ return h("div", { class: "chain" }, nodes);
253
+ }
254
+ function putEvent(ev, fresh) {
255
+ if (!ev.key) return;
256
+ const card = h("div", { class: "ev" + (fresh ? " fresh" : "") }, h("div", { class: "head" },
257
+ h("span", { class: "time" }, clock(ev.at)), h("span", { class: "name" }, ev.name), h("span", { class: "agg" }, (ev.aggregate || "") + "#" + (ev.id || "")),
258
+ h("span", { class: "agg" }, ev.source === "tap" ? "event log" : "journal")), chainOf(ev));
259
+ const old = seen.get(ev.key), feed = $("feed");
260
+ if (old) old.replaceWith(card); else feed.prepend(card);
261
+ seen.set(ev.key, card);
262
+ while (feed.children.length > 80) { const last = feed.lastChild; for (const [k, v] of seen) if (v === last) seen.delete(k); last.remove(); }
263
+ }
264
+ function renderEvents(events) {
265
+ if (isUnknown(events)) return put("feed", unknown(events));
266
+ if (!seen.size) [...events].reverse().forEach((e) => putEvent(e, false));
267
+ }
268
+
269
+ async function refresh() {
270
+ try {
271
+ const res = await fetch("/api/state", { cache: "no-store" });
272
+ const s = await res.json();
273
+ renderTiles(s.metrics); renderStrip(s.stages); renderCi(s.ci_runs, s.slowest_jobs);
274
+ renderWork(s.open_work); renderSessions(s.sessions); renderFan(s.fanout); renderEvents(s.events);
275
+ $("updated").textContent = "updated " + clock(s.generated_at);
276
+ } catch (e) { $("updated").textContent = "update failed: " + e.message; }
277
+ }
278
+ function listen() {
279
+ if (!window.EventSource) { $("live").textContent = "stream: unsupported"; return; }
280
+ const es = new EventSource("/api/events");
281
+ es.onopen = () => { $("live").textContent = "stream: live"; };
282
+ es.onerror = () => { $("live").textContent = "stream: reconnecting"; };
283
+ es.addEventListener("board", (m) => { const ev = JSON.parse(m.data); if (ev.key) putEvent(ev, true); });
284
+ }
285
+ $("toggle").addEventListener("click", () => {
286
+ const on = document.body.classList.toggle("tables-only");
287
+ $("toggle").setAttribute("aria-pressed", String(on)); $("toggle").textContent = on ? "Show charts" : "Show tables";
288
+ });
289
+ refresh(); setInterval(refresh, 10000); listen();
290
+ </script>
291
+ </body>
292
+ </html>