maf 0.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 (136) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +411 -0
  5. data/assets/agents-contract.md +80 -0
  6. data/assets/analyst +240 -0
  7. data/assets/coord +2936 -0
  8. data/assets/dashboard +553 -0
  9. data/assets/dashboard.html +341 -0
  10. data/assets/dispatcher +1687 -0
  11. data/assets/doc-graph-refresh +286 -0
  12. data/assets/env.sh +6 -0
  13. data/assets/git-hooks/post-commit +7 -0
  14. data/assets/git-hooks/post-merge +7 -0
  15. data/assets/git-hooks/pre-commit +32 -0
  16. data/assets/harness-hooks/board-watch-opencode.js +87 -0
  17. data/assets/harness-hooks/board-watch.rb +286 -0
  18. data/assets/harness-hooks/context-watch.rb +268 -0
  19. data/assets/harness-hooks/next-task-hermes.sh +48 -0
  20. data/assets/harness-hooks/next-task.rb +97 -0
  21. data/assets/harness-hooks/session-guard.rb +128 -0
  22. data/assets/taskrc.append +11 -0
  23. data/assets/vault +224 -0
  24. data/assets/worktree-env.example.rb +26 -0
  25. data/exe/maf +14 -0
  26. data/install.md +326 -0
  27. data/lib/maf/bootstrap/claude_settings.rb +55 -0
  28. data/lib/maf/bootstrap/dependencies.rb +37 -0
  29. data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
  30. data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
  31. data/lib/maf/bootstrap/graph_home.rb +62 -0
  32. data/lib/maf/bootstrap/hook_merger.rb +53 -0
  33. data/lib/maf/bootstrap/installer.rb +66 -0
  34. data/lib/maf/bootstrap/layout_planner.rb +18 -0
  35. data/lib/maf/bootstrap/marked_block.rb +44 -0
  36. data/lib/maf/bootstrap/memory_branch.rb +77 -0
  37. data/lib/maf/bootstrap/options.rb +34 -0
  38. data/lib/maf/bootstrap/project.rb +77 -0
  39. data/lib/maf/bootstrap/script_planner.rb +81 -0
  40. data/lib/maf/bootstrap/text_planner.rb +42 -0
  41. data/lib/maf/bootstrap/vault_starter.rb +41 -0
  42. data/lib/maf/bootstrap/writer.rb +69 -0
  43. data/lib/maf/bootstrap.rb +162 -0
  44. data/lib/maf/budget.rb +59 -0
  45. data/lib/maf/cli.rb +135 -0
  46. data/lib/maf/env_exclude.rb +23 -0
  47. data/lib/maf/flow/agent_links.rb +79 -0
  48. data/lib/maf/flow/bootstrapper.rb +36 -0
  49. data/lib/maf/flow/codex_hooks.rb +50 -0
  50. data/lib/maf/flow/generator.rb +63 -0
  51. data/lib/maf/flow/harness_linker.rb +37 -0
  52. data/lib/maf/flow/hermes_hook.rb +48 -0
  53. data/lib/maf/flow/hermes_hook_setup.rb +69 -0
  54. data/lib/maf/flow/hook_files.rb +16 -0
  55. data/lib/maf/flow/hook_installer.rb +33 -0
  56. data/lib/maf/flow/legacy_codex_hook.rb +71 -0
  57. data/lib/maf/flow/manifest.rb +51 -0
  58. data/lib/maf/flow/mcp_config.rb +72 -0
  59. data/lib/maf/flow/mcp_installer.rb +45 -0
  60. data/lib/maf/flow/models.rb +61 -0
  61. data/lib/maf/flow/options.rb +65 -0
  62. data/lib/maf/flow/prompt_builder.rb +85 -0
  63. data/lib/maf/flow/prompt_text.rb +263 -0
  64. data/lib/maf/flow/report.rb +89 -0
  65. data/lib/maf/flow/role_catalog.rb +40 -0
  66. data/lib/maf/flow/role_files.rb +72 -0
  67. data/lib/maf/flow/role_stub.rb +38 -0
  68. data/lib/maf/flow/roster.rb +28 -0
  69. data/lib/maf/flow/validator.rb +38 -0
  70. data/lib/maf/flow/workflow.rb +28 -0
  71. data/lib/maf/flow.rb +84 -0
  72. data/lib/maf/local_exclude.rb +53 -0
  73. data/lib/maf/menu.rb +101 -0
  74. data/lib/maf/migrate/moves.rb +44 -0
  75. data/lib/maf/migrate/rewrites.rb +53 -0
  76. data/lib/maf/migrate/role_files.rb +35 -0
  77. data/lib/maf/migrate/runner.rb +66 -0
  78. data/lib/maf/migrate/worktrees.rb +65 -0
  79. data/lib/maf/migrate.rb +62 -0
  80. data/lib/maf/prompt.rb +40 -0
  81. data/lib/maf/retire.rb +116 -0
  82. data/lib/maf/role_limits.rb +49 -0
  83. data/lib/maf/setup_agent/args.rb +57 -0
  84. data/lib/maf/setup_agent/dispatch.rb +44 -0
  85. data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
  86. data/lib/maf/setup_agent/hermes_skill.rb +26 -0
  87. data/lib/maf/setup_agent/launcher.rb +85 -0
  88. data/lib/maf/setup_agent/manifest.rb +35 -0
  89. data/lib/maf/setup_agent/project.rb +9 -0
  90. data/lib/maf/setup_agent/role_file.rb +30 -0
  91. data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
  92. data/lib/maf/setup_agent/worktree.rb +50 -0
  93. data/lib/maf/setup_agent.rb +111 -0
  94. data/lib/maf/shared/git_exclude.rb +33 -0
  95. data/lib/maf/shared/git_identity.rb +41 -0
  96. data/lib/maf/shared/peak_rate.rb +20 -0
  97. data/lib/maf/shared/processes.rb +31 -0
  98. data/lib/maf/shared/project.rb +34 -0
  99. data/lib/maf/shared/roles.rb +19 -0
  100. data/lib/maf/team.rb +114 -0
  101. data/lib/maf/team_command.rb +73 -0
  102. data/lib/maf/uninstall/claude_settings.rb +40 -0
  103. data/lib/maf/uninstall/codex_hooks.rb +18 -0
  104. data/lib/maf/uninstall/commit_guard.rb +16 -0
  105. data/lib/maf/uninstall/coordination.rb +15 -0
  106. data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
  107. data/lib/maf/uninstall/git.rb +13 -0
  108. data/lib/maf/uninstall/local_files.rb +33 -0
  109. data/lib/maf/uninstall/manifest.rb +29 -0
  110. data/lib/maf/uninstall/marked_files.rb +37 -0
  111. data/lib/maf/uninstall/mcp_entries.rb +43 -0
  112. data/lib/maf/uninstall/notes.rb +31 -0
  113. data/lib/maf/uninstall/owned.rb +12 -0
  114. data/lib/maf/uninstall/role_files.rb +51 -0
  115. data/lib/maf/uninstall/runner.rb +67 -0
  116. data/lib/maf/uninstall/scripts.rb +35 -0
  117. data/lib/maf/uninstall/vault_watcher.rb +21 -0
  118. data/lib/maf/uninstall/worktrees.rb +30 -0
  119. data/lib/maf/uninstall.rb +59 -0
  120. data/lib/maf/untrack.rb +90 -0
  121. data/lib/maf/version.rb +5 -0
  122. data/lib/maf/worker_archive.rb +63 -0
  123. data/lib/maf/worker_control.rb +137 -0
  124. data/lib/maf/workers.rb +37 -0
  125. data/lib/maf.rb +5 -0
  126. data/templates/claude.md.erb +16 -0
  127. data/templates/codex.md.erb +7 -0
  128. data/templates/hermes.md.erb +12 -0
  129. data/templates/opencode.md.erb +24 -0
  130. data/templates/role-stub.yml.erb +15 -0
  131. data/templates/roles.yml +289 -0
  132. data/templates/workflows/panel.md +20 -0
  133. data/templates/workflows/plan-review.md +9 -0
  134. data/templates/workflows/simple.md +4 -0
  135. data/templates/workflows/tdd.md +8 -0
  136. metadata +193 -0
data/assets/dashboard ADDED
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # dashboard - local observability web UI for multi-agent coordination.
5
+ #
6
+ # Usage: dashboard [--port N] [--coord DIR] [--interval S]
7
+ #
8
+ # Starts a local HTTP server at http://localhost:4567 (default).
9
+ # The page auto-refreshes every S seconds (default: 5).
10
+ # Run from the project root where .maf/coordination/ lives.
11
+ #
12
+ # >>> multi-agent-flow >>>
13
+ # <<< multi-agent-flow <<<
14
+
15
+ require "json"
16
+ require "fileutils"
17
+ require "time"
18
+ require "optparse"
19
+ require "rbconfig"
20
+ require "securerandom"
21
+ begin
22
+ require_relative "../lib/maf/shared/processes"
23
+ require_relative "../lib/maf/shared/peak_rate"
24
+ rescue LoadError
25
+ abort "dashboard: the maf shared library is missing. Run maf update."
26
+ end
27
+ begin
28
+ require "webrick"
29
+ rescue LoadError
30
+ abort "dashboard: webrick is required. Install it with: gem install webrick"
31
+ end
32
+
33
+ abort "dashboard: Ruby 3.0+ required (current: #{RUBY_VERSION})." if RUBY_VERSION.split(".").first.to_i < 3
34
+
35
+ module Dashboard
36
+ DEFAULT_PORT = 4567
37
+ DEFAULT_INTERVAL = 5
38
+ DEFAULT_COORD = ".maf/coordination"
39
+ DEFAULT_LEASE = 4 * 3600
40
+
41
+ class Config
42
+ attr_reader :port, :coord_dir, :interval, :maf
43
+
44
+ # Each flag: the OptionParser arguments and the instance variable it sets.
45
+ FLAGS = { ["--port N", Integer] => :@port, ["--coord DIR"] => :@coord_dir,
46
+ ["--interval S", Integer] => :@interval, ["--maf PATH"] => :@maf }.freeze
47
+
48
+ def initialize(argv)
49
+ @port, @coord_dir, @interval, @maf = DEFAULT_PORT, DEFAULT_COORD, DEFAULT_INTERVAL, "maf"
50
+ parser = OptionParser.new
51
+ FLAGS.each { |args, name| parser.on(*args) { |value| instance_variable_set(name, value) } }
52
+ parser.parse!(argv)
53
+ end
54
+
55
+ def taskrc = File.join(coord_dir, "taskrc")
56
+ def log_path = File.join(coord_dir, "events.log")
57
+ def inbox_dir = File.join(coord_dir, "inbox")
58
+ def locks_dir = File.join(coord_dir, "locks")
59
+ def manifest = File.join(File.dirname(coord_dir), "config.json")
60
+ def workers_path = File.join(coord_dir, "workers.json")
61
+ def vault_path = File.join(File.dirname(File.expand_path(coord_dir)), "bin", "vault")
62
+ def analyst_path = File.join(File.dirname(File.expand_path(coord_dir)), "bin", "analyst")
63
+ def root = File.dirname(File.dirname(File.expand_path(coord_dir)))
64
+ def lease_ttl
65
+ v = Integer(ENV.fetch("COORD_LEASE_TTL", ""), exception: false)
66
+ v&.positive? ? v : DEFAULT_LEASE
67
+ end
68
+ end
69
+
70
+ class TaskReader
71
+ def initialize(taskrc)
72
+ @taskrc = taskrc
73
+ end
74
+
75
+ QUERY = ["task", "(", "status:pending", "or", "status:waiting", ")", "export"].freeze
76
+
77
+ def read
78
+ return [] unless File.exist?(@taskrc)
79
+
80
+ IO.popen(ENV.to_h.merge("TASKRC" => @taskrc), QUERY, err: File::NULL) { |f| parse(f.read) }
81
+ rescue StandardError
82
+ []
83
+ end
84
+
85
+ private
86
+
87
+ def parse(raw)
88
+ raw.strip.empty? ? [] : JSON.parse(raw)
89
+ rescue JSON::ParserError
90
+ []
91
+ end
92
+ end
93
+
94
+ # GraphReader asks `vault age --json` for the graph age. An absent vault
95
+ # script or a failure gives nil.
96
+ class GraphReader
97
+ def initialize(cfg)
98
+ @cfg = cfg
99
+ end
100
+
101
+ def read
102
+ JSON.parse(IO.popen([RbConfig.ruby, @cfg.vault_path, "age", "--json"], chdir: @cfg.root, err: File::NULL, &:read))
103
+ rescue SystemCallError, JSON::ParserError
104
+ nil
105
+ end
106
+ end
107
+
108
+ # WorkerReader puts every fact about one worker in one record: the registry
109
+ # entry (.maf/coordination/workers.json), presence, the status that the
110
+ # dispatcher or the context-watch hook writes, token usage, the last log
111
+ # lines, and the result of the last dashboard action.
112
+ class WorkerReader
113
+ LOG_LINES = 5
114
+ HISTORY = 20
115
+
116
+ # last_events maps each worker to the time of its last event.
117
+ def initialize(cfg, last_events = {})
118
+ @cfg = cfg
119
+ @last_events = last_events
120
+ end
121
+
122
+ def read(id, entry)
123
+ entry.merge("id" => id, "live" => live?(file("presence", id)), "status" => file("status", id),
124
+ "usage" => file("usage", id), "log" => tail("#{id}.log"), "action" => tail("#{id}.control.log"),
125
+ "handoff_at" => mtime(File.join(sessions, "#{id}.handoff.md")), "last_event" => @last_events[id],
126
+ "runs" => history(id), "analysis" => file("hints", id))
127
+ end
128
+
129
+ private
130
+
131
+ def sessions = File.join(@cfg.coord_dir, "sessions")
132
+
133
+ def file(dir, id)
134
+ path = File.join(@cfg.coord_dir, dir, "#{id}.json")
135
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
136
+ rescue JSON::ParserError
137
+ {}
138
+ end
139
+
140
+ # A pid can belong to a new process. The recorded start time tells them apart.
141
+ def live?(presence)
142
+ pid = presence["pid"].to_i
143
+ Maf::Shared::Processes.alive?(pid) && [nil, "", Maf::Shared::Processes.started_at(pid)].include?(presence["started"])
144
+ end
145
+
146
+ def tail(name)
147
+ path = File.join(sessions, name)
148
+ File.exist?(path) ? File.readlines(path).last(LOG_LINES).map(&:chomp) : []
149
+ end
150
+
151
+ def mtime(path) = File.exist?(path) ? File.mtime(path).utc.iso8601 : nil
152
+
153
+ # The dispatcher appends one line per run. A broken line counts as no run.
154
+ def history(id)
155
+ path = File.join(@cfg.coord_dir, "usage", "#{id}.runs.jsonl")
156
+ lines = File.exist?(path) ? File.readlines(path).last(HISTORY) : []
157
+ lines.filter_map { |line| JSON.parse(line) rescue nil }
158
+ end
159
+ end
160
+
161
+ # ActionRunner runs `maf worker ACTION ID` in the background. A stop can
162
+ # wait for a running agent, so the request does not wait for the command.
163
+ # The output goes to sessions/<id>.control.log, which the page shows.
164
+ class ActionRunner
165
+ ACTIONS = %w[start stop restart analyze].freeze
166
+
167
+ def initialize(cfg)
168
+ @cfg = cfg
169
+ end
170
+
171
+ LIMITS = %w[max_context max_session_runs cache_window].freeze
172
+
173
+ # Returns an error text, or nil when the command started. limits are the
174
+ # session limits that a restart saves (see TokenHints).
175
+ def run(id, action, limits = {})
176
+ return "unknown action #{action}" unless ACTIONS.include?(action)
177
+ return "unknown worker #{id}" unless registered?(id)
178
+ return "unknown limit" unless limits.all? { |key, value| LIMITS.include?(key) && value.is_a?(Integer) }
179
+
180
+ return spawn_analyst(id) if action == "analyze"
181
+
182
+ spawn_maf(id, action, limits.flat_map { |key, value| ["--#{key.tr("_", "-")}", value.to_s] })
183
+ end
184
+
185
+ private
186
+
187
+ def start_log(id, action)
188
+ log = File.join(@cfg.coord_dir, "sessions", "#{id}.control.log")
189
+ FileUtils.mkdir_p(File.dirname(log))
190
+ File.write(log, "[#{Time.now.utc.iso8601}] #{action} #{id}\n", mode: "a")
191
+ log
192
+ end
193
+
194
+ def registered?(id)
195
+ JSON.parse(File.read(@cfg.workers_path)).key?(id)
196
+ rescue Errno::ENOENT, JSON::ParserError
197
+ false
198
+ end
199
+
200
+ # The analyst asks a small model for hints. It writes hints/<id>.json.
201
+ def spawn_analyst(id)
202
+ log = start_log(id, "analyst")
203
+ pid = spawn(RbConfig.ruby, @cfg.analyst_path, id, "--coord", File.expand_path(@cfg.coord_dir),
204
+ chdir: @cfg.root, in: File::NULL, out: [log, "a"], err: %i[child out])
205
+ Process.detach(pid) && nil
206
+ rescue SystemCallError => e
207
+ "analyst did not start: #{e.message}. Run maf update."
208
+ end
209
+
210
+ def spawn_maf(id, action, flags)
211
+ log = start_log(id, ["maf worker", action, *flags].join(" "))
212
+ pid = spawn(@cfg.maf, "worker", action, id, *flags, chdir: @cfg.root, in: File::NULL, out: [log, "a"],
213
+ err: %i[child out])
214
+ Process.detach(pid) && nil
215
+ rescue SystemCallError => e
216
+ "maf did not start: #{e.message}. Put maf on PATH or pass --maf PATH."
217
+ end
218
+ end
219
+
220
+ # TokenHints reads the run history of a dispatched worker and suggests
221
+ # better session limits. The page shows each hint with a button that
222
+ # restarts the worker with the suggested limit. Only the runs with the
223
+ # limits of the last run count, so a restart with new limits clears the
224
+ # hints of the old limits. assets/dispatcher writes the history (RunHistory).
225
+ class TokenHints
226
+ RATIO = 3
227
+ MIN_RUNS = 2
228
+ COLD_WRITE = 0.25
229
+ MIN_CONTEXT = 40_000
230
+
231
+ def initialize(worker, now = Time.now.utc)
232
+ @id, @now = worker["id"], now
233
+ runs = worker["runs"] || []
234
+ @runs = runs.select { |run| run["limits"] == runs.last["limits"] }
235
+ end
236
+
237
+ def list = @runs.empty? ? [] : [growth, cold, peak].compact
238
+
239
+ private
240
+
241
+ def fresh = @runs.select { |run| run["session_run"] == 1 }
242
+ def resumed = @runs.reject { |run| run["session_run"] == 1 }
243
+ def limits = @runs.last["limits"] || {}
244
+ def median(runs, key) = runs.map { |run| run[key].to_i }.sort[runs.size / 2]
245
+ def hint(level, text, limits = nil) = { worker: @id, level: level, text: text, limits: limits }.compact
246
+
247
+ # Each resume sends the old context again, so a long session costs more per run.
248
+ def growth
249
+ return unless fresh.any? && resumed.size >= MIN_RUNS && ratio >= RATIO
250
+
251
+ @runs.any? { |run| run["context"] } ? context_cap : fresh_runs
252
+ end
253
+
254
+ def ratio = median(resumed, "input_tokens").fdiv([median(fresh, "input_tokens"), 1].max)
255
+
256
+ def growth_text
257
+ "#{@id}: a resumed run uses #{ratio.round(1)}x the input of a fresh run " \
258
+ "(#{tokens(median(resumed, "input_tokens"))} vs #{tokens(median(fresh, "input_tokens"))})."
259
+ end
260
+
261
+ def context_cap
262
+ cap = [(median(fresh, "context") * 2 / 10_000.0).ceil * 10_000, MIN_CONTEXT].max
263
+ return if limits["max_context"].to_i.positive? && limits["max_context"] <= cap
264
+
265
+ hint("warn", "#{growth_text} Cap the session context at #{cap / 1000}k tokens.", "max_context" => cap)
266
+ end
267
+
268
+ def fresh_runs
269
+ return if limits["max_session_runs"] == 1
270
+
271
+ hint("warn", "#{growth_text} Start each run fresh with the handoff note.", "max_session_runs" => 1)
272
+ end
273
+
274
+ # A large cache write on a resume means that the cache expired first.
275
+ def cold
276
+ runs = resumed.select { |run| run["cache_write_input_tokens"].to_i > COLD_WRITE * run["input_tokens"].to_i }
277
+ window = limits["cache_window"].to_i / 2
278
+ return if runs.size < MIN_RUNS || window < 60
279
+
280
+ hint("warn", "#{@id}: #{runs.size} resumed runs found a cold cache. Lower the cache window to #{window} s.",
281
+ "cache_window" => window)
282
+ end
283
+
284
+ def peak
285
+ hours = Maf::Shared::PeakRate.hours(@now)
286
+ return unless @runs.last["peak"] && hours
287
+
288
+ hint("info", "#{@id}: DeepSeek runs cost 2x the off-peak rate until #{hours.end}:00 UTC. " \
289
+ "Stop the worker to wait for the off-peak rate.").merge(action: "stop")
290
+ end
291
+
292
+ def tokens(count) = count >= 1_000_000 ? "#{(count / 1e6).round(1)}M" : "#{(count / 1e3).round}k"
293
+ end
294
+
295
+ # Collector builds the data of one page refresh.
296
+ class Collector
297
+ def initialize(cfg)
298
+ @cfg = cfg
299
+ @files = BoardFiles.new(cfg)
300
+ end
301
+
302
+ def collect
303
+ { project: File.basename(Dir.pwd), generated_at: Time.now.utc.iso8601, refresh_interval: @cfg.interval,
304
+ lease_ttl: @cfg.lease_ttl, declared_roles: declared_roles }.merge(board)
305
+ end
306
+
307
+ private
308
+
309
+ def board
310
+ tasks = TaskReader.new(@cfg.taskrc).read
311
+ list = workers
312
+ { workers: list, hints: list.flat_map { |worker| TokenHints.new(worker).list + analysis(worker) },
313
+ graph: GraphReader.new(@cfg).read, tasks: TaskSummary.new(@cfg, declared_roles).of(tasks),
314
+ events: @files.recent_events, inboxes: @files.inbox_messages, locks: @files.active_locks }
315
+ end
316
+
317
+ # The analyst writes running, then its hints or its error.
318
+ def analysis(worker)
319
+ data = worker["analysis"] || {}
320
+ base = { worker: worker["id"], level: "analysis", at: data["at"] }
321
+ return [base.merge(text: "#{worker["id"]}: the analyst runs.")] if data["running"]
322
+ return [base.merge(text: "#{worker["id"]}: the analyst failed: #{data["error"]}")] if data["error"]
323
+
324
+ Array(data["hints"]).map { |hint| base.merge(text: "#{worker["id"]}: #{hint["text"]}", limits: hint["limits"]) }
325
+ end
326
+
327
+ def declared_roles
328
+ JSON.parse(File.read(@cfg.manifest)).fetch("agents", []).map { |entry| entry["role"] }.compact
329
+ rescue Errno::ENOENT, JSON::ParserError
330
+ []
331
+ end
332
+
333
+ # .maf/coordination/workers.json lists each worker that `maf prepare` or
334
+ # `maf start` set up. The last event shows when the worker last acted.
335
+ def workers
336
+ reader = WorkerReader.new(@cfg, @files.recent_events(500).to_h { |event| [event[:worker], event[:ts]] })
337
+ JSON.parse(File.read(@cfg.workers_path)).map { |id, entry| reader.read(id, entry) }
338
+ rescue Errno::ENOENT, JSON::ParserError
339
+ []
340
+ end
341
+ end
342
+
343
+ # TaskSummary sorts the pending tasks into the lists that raise alerts.
344
+ class TaskSummary
345
+ def initialize(cfg, roles)
346
+ @cfg = cfg
347
+ @roles = roles
348
+ end
349
+
350
+ def of(tasks)
351
+ { all: tasks, stale: tasks.select { |t| stale?(t) }, orphaned: orphaned(tasks),
352
+ conflicts: scope_conflicts(tasks), escalated: tasks.select { |t| note?(t, "ESCALATED:") },
353
+ in_review: tasks.select { |t| t["role"] == "goal" && note?(t, "PR:") } }
354
+ end
355
+
356
+ private
357
+
358
+ # `coord escalate --task` writes an ESCALATED note. `coord goal pr` writes
359
+ # a PR note on the goal. A done task leaves the pending list.
360
+ def note?(task, prefix) = (task["annotations"] || []).any? { |note| note["description"].to_s.start_with?(prefix) }
361
+
362
+ def stale?(task)
363
+ start = task["start"].to_s
364
+ !start.empty? && Time.parse(start) + @cfg.lease_ttl <= Time.now
365
+ rescue ArgumentError
366
+ false
367
+ end
368
+
369
+ # Goals are not work for a role, so a goal never lacks a runner.
370
+ def orphaned(tasks)
371
+ return [] if @roles.empty?
372
+
373
+ tasks.select { |t| t["role"] && t["role"] != "goal" && !@roles.include?(t["role"]) }
374
+ end
375
+
376
+ def scope_conflicts(tasks)
377
+ tasks.combination(2).select { |a, b| overlap?(a["scope"], b["scope"]) }.map { |a, b| [a["uuid"], b["uuid"]] }
378
+ end
379
+
380
+ def overlap?(first, second)
381
+ short, long = [parts(first), parts(second)].sort_by(&:size)
382
+ !short.empty? && short == long.first(short.size)
383
+ end
384
+
385
+ def parts(scope) = scope.to_s.split("/").take_while { |part| part !~ /[*?{}]/ }
386
+ end
387
+
388
+ # BoardFiles reads the event log, the inboxes, and the locks.
389
+ class BoardFiles
390
+ def initialize(cfg)
391
+ @cfg = cfg
392
+ end
393
+
394
+ def recent_events(limit = 60)
395
+ path = @cfg.log_path
396
+ File.exist?(path) ? File.readlines(path).last(limit).filter_map { |line| parse_event(line.chomp) } : []
397
+ end
398
+
399
+ def inbox_messages
400
+ roles = Dir.exist?(@cfg.inbox_dir) ? Dir.children(@cfg.inbox_dir) : []
401
+ roles.select { |role| File.directory?(File.join(@cfg.inbox_dir, role)) }.to_h { |role| [role, messages(role)] }
402
+ end
403
+
404
+ def active_locks = Dir.glob(File.join(@cfg.locks_dir, "*.json")).filter_map { |path| read_lock(path) }
405
+
406
+ private
407
+
408
+ def parse_event(line)
409
+ ts, action, worker, detail = line.split("\t", 4)
410
+ ts && action ? { ts: ts, action: action, worker: worker, detail: detail } : nil
411
+ end
412
+
413
+ def messages(role) = Dir.glob(File.join(@cfg.inbox_dir, role, "*.md")).sort.filter_map { |p| read_message(p) }
414
+
415
+ def read_message(path)
416
+ header, body = File.read(path).split("\n\n", 2)
417
+ { from: header[/^# from: (.+)$/, 1], at: header[/^# at: (.+)$/, 1], preview: body.to_s.strip.slice(0, 140) }
418
+ rescue Errno::ENOENT
419
+ nil
420
+ end
421
+
422
+ # A lock without a ttl is a mutex of `coord with-lock`. Only advisory locks expire.
423
+ def read_lock(meta)
424
+ data = JSON.parse(File.read(meta))
425
+ ts, ttl = data["ts"].to_i, data["ttl"].to_i
426
+ ttl.positive? ? lock_record(File.basename(meta, ".json"), data["worker"], ts, ttl) : nil
427
+ rescue JSON::ParserError, Errno::ENOENT
428
+ nil
429
+ end
430
+
431
+ def lock_record(name, worker, ts, ttl)
432
+ { name: name, worker: worker, acquired_at: Time.at(ts).utc.iso8601,
433
+ expires_at: Time.at(ts + ttl).utc.iso8601, stale: ts + ttl < Time.now.to_i }
434
+ end
435
+ end
436
+
437
+ # Page reads the HTML page. It lives next to this script: the installer copies both.
438
+ module Page
439
+ PATH = File.join(__dir__, "dashboard.html")
440
+
441
+ def self.html
442
+ File.read(PATH)
443
+ rescue Errno::ENOENT
444
+ abort "dashboard: #{PATH} is missing. Run maf update."
445
+ end
446
+ end
447
+
448
+ # BoardCheck finds out if the coordination folder exists. The default folder
449
+ # is relative, so a wrong working directory gives an empty page without it.
450
+ class BoardCheck
451
+ def initialize(cfg)
452
+ @cfg = cfg
453
+ end
454
+
455
+ # Returns an error text, or nil if the task board exists.
456
+ def problem
457
+ return nil if File.exist?(@cfg.taskrc)
458
+ "no task board at #{File.expand_path(@cfg.coord_dir)}.\n" \
459
+ "Run dashboard from the project root, or pass --coord DIR.#{old_layout_hint}"
460
+ end
461
+
462
+ private
463
+
464
+ def old_layout_hint
465
+ File.exist?("coordination/taskrc") ? "\nThis project uses the old layout. Run: maf migrate" : ""
466
+ end
467
+ end
468
+
469
+ # Server serves the page, the data, and the worker actions. An action needs
470
+ # the token of this server run, which only the page knows, and a localhost
471
+ # Host header. So another web site in the browser cannot start an action.
472
+ class Server
473
+ def initialize(cfg)
474
+ @cfg = cfg
475
+ @collector = Collector.new(cfg)
476
+ @actions = ActionRunner.new(cfg)
477
+ @token = SecureRandom.hex(16)
478
+ @page = Page.html
479
+ end
480
+
481
+ def run
482
+ BoardCheck.new(@cfg).problem&.then { |problem| abort "dashboard: #{problem}" }
483
+ server = mount(build_server)
484
+ trap("INT") { server.shutdown }
485
+ puts "dashboard: http://localhost:#{@cfg.port} (ctrl-c to stop)"
486
+ server.start
487
+ end
488
+
489
+ private
490
+
491
+ def mount(server)
492
+ server.mount_proc("/") { |req, res| local(req, res) { html(res) } }
493
+ server.mount_proc("/data") { |req, res| local(req, res) { json(res) } }
494
+ server.mount_proc("/action") { |req, res| action(req, res) }
495
+ server
496
+ end
497
+
498
+ def build_server
499
+ WEBrick::HTTPServer.new(BindAddress: "127.0.0.1",
500
+ Port: @cfg.port,
501
+ Logger: WEBrick::Log.new(File::NULL),
502
+ AccessLog: [])
503
+ end
504
+
505
+ def html(res)
506
+ res.content_type = "text/html; charset=utf-8"
507
+ res.body = @page.sub("__TOKEN__", @token)
508
+ end
509
+
510
+ def action(req, res)
511
+ error = refusal(req) || run_action(JSON.parse(req.body.to_s))
512
+ answer(res, error ? 400 : 202, error ? { error: error } : { ok: true })
513
+ rescue JSON::ParserError
514
+ answer(res, 400, error: "the body is not JSON")
515
+ end
516
+
517
+ def run_action(body)
518
+ limits = body["limits"].is_a?(Hash) ? body["limits"] : {}
519
+ @actions.run(body["worker"].to_s, body["action"].to_s, limits)
520
+ end
521
+
522
+ def answer(res, status, data)
523
+ res.content_type = "application/json"
524
+ res.status = status
525
+ res.body = JSON.generate(data)
526
+ end
527
+
528
+ def refusal(req)
529
+ return "use POST" unless req.request_method == "POST"
530
+ return "wrong token" unless req["X-Maf-Token"] == @token
531
+
532
+ "wrong host" unless local_host?(req)
533
+ end
534
+
535
+ # DNS rebinding points a foreign name at 127.0.0.1. The Host header still
536
+ # names the foreign site, so the page and the board data stay private.
537
+ def local(req, res)
538
+ return yield if local_host?(req)
539
+
540
+ res.status = 403
541
+ res.body = "wrong host"
542
+ end
543
+
544
+ def local_host?(req) = req.host.to_s.match?(/\A(localhost|127\.0\.0\.1)\z/)
545
+
546
+ def json(res)
547
+ res.content_type = "application/json"
548
+ res.body = JSON.generate(@collector.collect)
549
+ end
550
+ end
551
+ end
552
+
553
+ Dashboard::Server.new(Dashboard::Config.new(ARGV)).run if __FILE__ == $PROGRAM_NAME