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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- 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
|