maf 0.3.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +4 -0
- data/assets/coord +60 -8
- data/assets/dispatcher +82 -5
- data/assets/steward +398 -0
- data/lib/maf/backup.rb +63 -0
- data/lib/maf/bootstrap/installer.rb +2 -2
- data/lib/maf/bootstrap/script_planner.rb +4 -0
- data/lib/maf/bootstrap.rb +1 -0
- data/lib/maf/budget.rb +2 -0
- data/lib/maf/cli.rb +24 -5
- data/lib/maf/flow/prompt_text.rb +22 -4
- data/lib/maf/op_command.rb +28 -0
- data/lib/maf/restore.rb +62 -0
- data/lib/maf/session_records.rb +39 -0
- data/lib/maf/setup_agent/steward.rb +18 -0
- data/lib/maf/setup_agent.rb +2 -0
- data/lib/maf/shared/flow_files.rb +33 -0
- data/lib/maf/shared/sandbox/paths.rb +10 -2
- data/lib/maf/shared/sandbox/seatbelt.rb +23 -2
- data/lib/maf/shared/sandbox.rb +7 -1
- data/lib/maf/team.rb +10 -2
- data/lib/maf/team_command.rb +8 -1
- data/lib/maf/uninstall/scripts.rb +1 -0
- data/lib/maf/untrack.rb +17 -1
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -2
- data/lib/maf/worker_list.rb +2 -13
- metadata +8 -1
data/assets/steward
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# steward - run the host operations that the leads request.
|
|
5
|
+
#
|
|
6
|
+
# Usage: steward [start|stop|status|daemon]
|
|
7
|
+
# steward # start the daemon; no-op if it already runs
|
|
8
|
+
# steward stop
|
|
9
|
+
# steward status
|
|
10
|
+
#
|
|
11
|
+
# An agent runs `coord op request OPERATION [ARGUMENT]`. coord writes the
|
|
12
|
+
# request to .maf/coordination/ops/requests/. The daemon runs only the
|
|
13
|
+
# operations in Steward::Ops::TABLE, outside the sandbox, and sends the
|
|
14
|
+
# result to the inbox of the role that asked.
|
|
15
|
+
require "json"
|
|
16
|
+
require "fileutils"
|
|
17
|
+
require "rbconfig"
|
|
18
|
+
require "time"
|
|
19
|
+
begin
|
|
20
|
+
require_relative "../lib/maf/shared/project"
|
|
21
|
+
require_relative "../lib/maf/shared/processes"
|
|
22
|
+
require_relative "../lib/maf/shared/sandbox"
|
|
23
|
+
rescue LoadError
|
|
24
|
+
abort "steward: the maf shared library is missing. Run maf update."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Steward runs the operations of Steward::Ops::TABLE for the leads.
|
|
28
|
+
module Steward
|
|
29
|
+
COORD_DIR = File.join(".maf", "coordination")
|
|
30
|
+
OPS_DIR = File.join(COORD_DIR, "ops")
|
|
31
|
+
PID_FILE = File.join(COORD_DIR, "steward.pid")
|
|
32
|
+
LOG_FILE = File.join(COORD_DIR, "steward.log")
|
|
33
|
+
POLL = 3
|
|
34
|
+
|
|
35
|
+
COMMANDS = { "stop" => :stop, "status" => :status, "daemon" => :loop_forever }.freeze
|
|
36
|
+
|
|
37
|
+
def self.run(argv)
|
|
38
|
+
Dir.chdir(Maf::Shared::Project.root)
|
|
39
|
+
return Approvals.run(argv) if Approvals::COMMANDS.include?(argv.first)
|
|
40
|
+
|
|
41
|
+
Daemon.public_send(COMMANDS.fetch(argv.first, :start))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.coord = [RbConfig.ruby, File.join(".maf", "bin", "coord")]
|
|
45
|
+
|
|
46
|
+
# Daemon starts, stops, and runs the poll loop. It exits when the pid file is gone.
|
|
47
|
+
module Daemon
|
|
48
|
+
NESTED = "steward: this process runs in the sandbox. Start steward from a terminal."
|
|
49
|
+
LOG = { out: LOG_FILE, err: LOG_FILE, pgroup: true }.freeze
|
|
50
|
+
|
|
51
|
+
def self.start
|
|
52
|
+
abort NESTED if inside_sandbox?
|
|
53
|
+
return puts("steward: already running (pid #{Pid.read})") if Pid.running?
|
|
54
|
+
|
|
55
|
+
spawn_daemon
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.inside_sandbox? = Maf::Shared::Sandbox::Seatbelt.inside? || ENV["MAF_BWRAP"] == "1"
|
|
59
|
+
|
|
60
|
+
def self.spawn_daemon
|
|
61
|
+
FileUtils.mkdir_p(COORD_DIR)
|
|
62
|
+
pid = Process.spawn(RbConfig.ruby, File.expand_path(__FILE__), "daemon", **LOG)
|
|
63
|
+
Process.detach(pid) && Pid.write(pid)
|
|
64
|
+
puts "steward: started (pid #{pid}), log at #{LOG_FILE}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.stop
|
|
68
|
+
return puts("steward: not running") unless Pid.running?
|
|
69
|
+
|
|
70
|
+
Process.kill("TERM", Pid.read)
|
|
71
|
+
Pid.clear
|
|
72
|
+
puts "steward: stopped"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.status = puts(Pid.running? ? "steward: running (pid #{Pid.read})" : "steward: not running")
|
|
76
|
+
|
|
77
|
+
def self.loop_forever
|
|
78
|
+
Pid.write(Process.pid)
|
|
79
|
+
Signal.trap("TERM") { exit }
|
|
80
|
+
tick while File.exist?(PID_FILE)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.tick
|
|
84
|
+
Queue.new(OPS_DIR).each_request { |request| Handler.new(request).run }
|
|
85
|
+
sleep POLL
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Pid reads and writes the pid file. A non-positive pid is not a process.
|
|
90
|
+
module Pid
|
|
91
|
+
def self.running? = read.positive? && Maf::Shared::Processes.alive?(read)
|
|
92
|
+
def self.read = File.exist?(PID_FILE) ? File.read(PID_FILE).to_i : 0
|
|
93
|
+
def self.write(pid) = File.write(PID_FILE, pid)
|
|
94
|
+
def self.clear = FileUtils.rm_f(PID_FILE)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Queue moves each request file to done/ before the handler runs it, so a
|
|
98
|
+
# restart of the daemon never runs a request twice.
|
|
99
|
+
class Queue
|
|
100
|
+
def initialize(dir)
|
|
101
|
+
@dir = dir
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def each_request
|
|
105
|
+
Dir.glob(File.join(@dir, "requests", "*.json")).each do |path|
|
|
106
|
+
request = take(path)
|
|
107
|
+
yield request if request
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def take(path)
|
|
114
|
+
done = done_path(path)
|
|
115
|
+
File.rename(path, done)
|
|
116
|
+
JSON.parse(File.read(done)).merge("path" => done)
|
|
117
|
+
rescue JSON::ParserError, SystemCallError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def done_path(path)
|
|
122
|
+
File.join(@dir, "done", File.basename(path)).tap { |done| FileUtils.mkdir_p(File.dirname(done)) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Ops holds the fixed list of operations. Each one maps validated
|
|
127
|
+
# arguments to an argument list. No request text reaches a shell.
|
|
128
|
+
module Ops
|
|
129
|
+
TABLE = {
|
|
130
|
+
"worker-restart" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "worker", "restart", w["id"]] } },
|
|
131
|
+
"worker-retire" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "retire", w["id"]] } },
|
|
132
|
+
"worktree-sync" => { args: %i[worker], level: "auto",
|
|
133
|
+
argv: ->(w) { [*Steward.coord, "worktree", w["role"], w["worker_id"]] } },
|
|
134
|
+
"goal-sync" => { args: %i[goal], level: "auto", argv: ->(g) { [*Steward.coord, "goal", "sync", g] } },
|
|
135
|
+
"runtime-restore" => { args: [], level: "auto", argv: -> { %w[maf update] } },
|
|
136
|
+
"worker-start" => { args: %i[worker], level: "approve", argv: ->(w) { ["maf", "worker", "start", w["id"]] } },
|
|
137
|
+
"worker-model" => { args: %i[worker model], level: "approve", argv: ->(w, m) { Ops.replace(w, m) } },
|
|
138
|
+
"bundle-install" => { args: [], level: "approve",
|
|
139
|
+
argv: -> { Validate.lockfile && [{ "BUNDLE_FROZEN" => "true" }, "bundle", "install"] } }
|
|
140
|
+
}.freeze
|
|
141
|
+
|
|
142
|
+
def self.spec(name) = TABLE.fetch(name) { raise Refused, "unknown operation #{name}. Known: #{known}" }
|
|
143
|
+
def self.known = TABLE.keys.join(" ")
|
|
144
|
+
def self.level(name) = Levels.read.fetch(name, spec(name)[:level])
|
|
145
|
+
|
|
146
|
+
def self.argv(name, args)
|
|
147
|
+
kinds = spec(name)[:args]
|
|
148
|
+
raise Refused, "#{name} takes #{kinds.size} argument(s): #{kinds.join(" ")}" unless args.size == kinds.size
|
|
149
|
+
|
|
150
|
+
spec(name)[:argv].call(*kinds.zip(args).map { |kind, arg| Validate.public_send(kind, arg) })
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.replace(worker, model)
|
|
154
|
+
id = "#{worker["role"]}_#{worker["worker_id"]}"
|
|
155
|
+
["maf", "prepare", worker["harness"], id, "--model", model, "--replace", id,
|
|
156
|
+
worker["dispatch"] ? "--dispatch" : "--interactive"]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Levels reads the key ops of .maf/sandbox.json. The sandbox protects that file.
|
|
161
|
+
module Levels
|
|
162
|
+
VALUES = %w[auto approve off].freeze
|
|
163
|
+
|
|
164
|
+
def self.read
|
|
165
|
+
data = JSON.parse(File.read(File.join(".maf", "sandbox.json"))).fetch("ops", {})
|
|
166
|
+
data.is_a?(Hash) ? data.select { |_, level| VALUES.include?(level) } : {}
|
|
167
|
+
rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
|
|
168
|
+
{}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class Refused < StandardError; end
|
|
173
|
+
|
|
174
|
+
# Validate checks each argument against the project state, not against the request.
|
|
175
|
+
module Validate
|
|
176
|
+
WORKTREES = File.join(".maf", "worktrees")
|
|
177
|
+
|
|
178
|
+
def self.model(arg)
|
|
179
|
+
return arg if arg.to_s.match?(%r{\A[A-Za-z0-9][A-Za-z0-9._:/@-]{0,99}\z})
|
|
180
|
+
|
|
181
|
+
raise Refused, "a model is letters, digits, and . _ : / @ -"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def self.lockfile = File.exist?("Gemfile.lock") || raise(Refused, "the project has no Gemfile.lock")
|
|
185
|
+
|
|
186
|
+
def self.worker(arg)
|
|
187
|
+
entry = workers.fetch(arg.to_s) { raise Refused, "no worker #{arg}" }
|
|
188
|
+
raise Refused, "worker #{arg} is outside #{WORKTREES}" unless inside_worktrees?(entry["dir"].to_s)
|
|
189
|
+
|
|
190
|
+
entry.merge("id" => arg.to_s)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.goal(arg)
|
|
194
|
+
raise Refused, "a goal id is 8 hex characters" unless arg.to_s.match?(/\A\h{8}\z/)
|
|
195
|
+
raise Refused, "no goal branch goal/#{arg}" unless system("git", "rev-parse", "-q", "--verify",
|
|
196
|
+
"refs/heads/goal/#{arg}", out: File::NULL)
|
|
197
|
+
|
|
198
|
+
arg
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.workers
|
|
202
|
+
JSON.parse(File.read(File.join(COORD_DIR, "workers.json")))
|
|
203
|
+
rescue JSON::ParserError, SystemCallError
|
|
204
|
+
{}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.inside_worktrees?(dir)
|
|
208
|
+
root = File.realpath(WORKTREES)
|
|
209
|
+
File.realpath(dir).start_with?("#{root}/")
|
|
210
|
+
rescue SystemCallError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Limit allows each operation 3 runs per hour for one argument. A loop of
|
|
216
|
+
# restarts then stops, and the project manager hears about it.
|
|
217
|
+
class Limit
|
|
218
|
+
MAX = 3
|
|
219
|
+
WINDOW = 3600
|
|
220
|
+
|
|
221
|
+
def initialize(path) = @path = path
|
|
222
|
+
|
|
223
|
+
def allow?(key) = recent.count(key) < MAX
|
|
224
|
+
def record(key) = File.open(@path, "a") { |file| file.puts(JSON.generate("at" => Time.now.to_i, "key" => key)) }
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def recent
|
|
229
|
+
return [] unless File.exist?(@path)
|
|
230
|
+
|
|
231
|
+
File.readlines(@path).filter_map { |line| entry(line) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def entry(line)
|
|
235
|
+
data = JSON.parse(line)
|
|
236
|
+
data["key"] if Time.now.to_i - data["at"].to_i < WINDOW
|
|
237
|
+
rescue JSON::ParserError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Handler runs one request and reports the result to the role that asked.
|
|
243
|
+
class Handler
|
|
244
|
+
TIMEOUT = 2000
|
|
245
|
+
|
|
246
|
+
def initialize(request, runner: Runner, limit: Limit.new(File.join(OPS_DIR, "history.jsonl")), approved: false)
|
|
247
|
+
@request = request
|
|
248
|
+
@runner = runner
|
|
249
|
+
@limit = limit
|
|
250
|
+
@approved = approved
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def run
|
|
254
|
+
route(Ops.argv(name, args), Ops.level(name))
|
|
255
|
+
rescue Refused => e
|
|
256
|
+
report(false, "refused: #{e.message}")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def route(argv, level)
|
|
260
|
+
raise Refused, "#{name} is off in .maf/sandbox.json" if level == "off"
|
|
261
|
+
return park if level == "approve" && !@approved
|
|
262
|
+
|
|
263
|
+
execute(argv)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def execute(argv)
|
|
267
|
+
return over_limit unless @limit.allow?(key)
|
|
268
|
+
|
|
269
|
+
@limit.record(key)
|
|
270
|
+
report(*@runner.call(argv, TIMEOUT))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def name = @request["op"].to_s
|
|
276
|
+
def args = Array(@request["args"]).map(&:to_s)
|
|
277
|
+
def key = [name, *args].join(" ")
|
|
278
|
+
|
|
279
|
+
def park
|
|
280
|
+
File.rename(@request["path"], Approvals.pending_path(@request["id"]))
|
|
281
|
+
text = "op #{@request["id"]} #{key}: waits for the user. The user runs: maf op approve #{@request["id"]}"
|
|
282
|
+
Reply.event(text)
|
|
283
|
+
[@request["role"].to_s, "project-manager"].uniq.each { |role| Reply.deliver(role, text) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def over_limit
|
|
287
|
+
report(false, "refused: #{key} ran #{Limit::MAX} times in the last hour")
|
|
288
|
+
Reply.deliver("project-manager", "ESCALATION from steward: #{key} ran #{Limit::MAX} times in an hour. " \
|
|
289
|
+
"Check the cause before a new request.")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def report(success, output)
|
|
293
|
+
text = "op #{@request["id"]} #{key}: #{success ? "done" : "failed"}. #{output.to_s.lines.last(5).join.strip}"
|
|
294
|
+
File.write(@request["path"], JSON.generate(@request.merge("ok" => success, "output" => output.to_s)))
|
|
295
|
+
Reply.event(text)
|
|
296
|
+
Reply.deliver(@request["role"].to_s, text)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Approvals lists, approves, and denies the waiting requests. The user runs
|
|
301
|
+
# them through maf op, outside the sandbox.
|
|
302
|
+
module Approvals
|
|
303
|
+
COMMANDS = %w[list approve deny].freeze
|
|
304
|
+
ID = /\A\d{8}T\d{6}Z-\h{6}\z/
|
|
305
|
+
REFUSED = "steward: only the user approves, from a terminal outside the sandbox."
|
|
306
|
+
|
|
307
|
+
def self.run(argv)
|
|
308
|
+
abort REFUSED if Daemon.inside_sandbox? || !ENV["COORD_ROLE"].to_s.empty?
|
|
309
|
+
argv.first == "list" ? list : public_send(argv.first, argv[1].to_s)
|
|
310
|
+
rescue Refused => e
|
|
311
|
+
abort "steward: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.list
|
|
315
|
+
files = Dir.glob(File.join(OPS_DIR, "pending", "*.json"))
|
|
316
|
+
puts "No request waits." if files.empty?
|
|
317
|
+
files.each { |path| puts line(JSON.parse(File.read(path))) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def self.line(req) = [req["id"], [req["op"], *req["args"]].join(" "), "from #{req["worker"]}"].join(" ")
|
|
321
|
+
|
|
322
|
+
def self.approve(id)
|
|
323
|
+
done = File.join(OPS_DIR, "done", "#{id}.json")
|
|
324
|
+
File.rename(waiting(id), done)
|
|
325
|
+
Handler.new(JSON.parse(File.read(done)).merge("path" => done), approved: true).run
|
|
326
|
+
puts File.read(done)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def self.deny(id)
|
|
330
|
+
request = JSON.parse(File.read(waiting(id)))
|
|
331
|
+
File.delete(waiting(id))
|
|
332
|
+
notify(request, "op #{id} #{[request["op"], *request["args"]].join(" ")}: denied by the user.")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.notify(request, text)
|
|
336
|
+
Reply.event(text)
|
|
337
|
+
Reply.deliver(request["role"].to_s, text)
|
|
338
|
+
puts text
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def self.waiting(id) = pending_path(id).tap { |path| File.exist?(path) || raise(Refused, "no request #{id}") }
|
|
342
|
+
|
|
343
|
+
def self.pending_path(id)
|
|
344
|
+
raise Refused, "a request id looks like 20261021T120000Z-a1b2c3" unless id.to_s.match?(ID)
|
|
345
|
+
|
|
346
|
+
File.join(OPS_DIR, "pending", "#{id}.json").tap { |path| FileUtils.mkdir_p(File.dirname(path)) }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Runner runs an argument list without a shell and stops it at the timeout.
|
|
351
|
+
module Runner
|
|
352
|
+
def self.call(argv, timeout)
|
|
353
|
+
reader, writer = IO.pipe
|
|
354
|
+
pid = start(argv, writer)
|
|
355
|
+
writer.close
|
|
356
|
+
pid ? collect(pid, reader, timeout) : [false, "cannot start #{argv.first}"]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.start(argv, writer)
|
|
360
|
+
Process.spawn(*argv, out: writer, err: writer, pgroup: true)
|
|
361
|
+
rescue SystemCallError
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.collect(pid, reader, timeout)
|
|
366
|
+
output = Thread.new { reader.read }
|
|
367
|
+
[succeeded?(pid, timeout), output.value]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def self.succeeded?(pid, timeout)
|
|
371
|
+
deadline = Time.now + timeout
|
|
372
|
+
sleep 0.2 until (status = Process.waitpid2(pid, Process::WNOHANG)&.last) || Time.now > deadline
|
|
373
|
+
kill(pid) unless status
|
|
374
|
+
status&.success? || false
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def self.kill(pid)
|
|
378
|
+
Process.kill("KILL", -pid)
|
|
379
|
+
Process.wait(pid)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Reply writes the event log line and the inbox message.
|
|
384
|
+
module Reply
|
|
385
|
+
def self.event(text)
|
|
386
|
+
line = [Time.now.utc.iso8601, "op", "steward", text.tr("\t\n", " ")].join("\t")
|
|
387
|
+
File.open(File.join(COORD_DIR, "events.log"), "a") { |file| file.puts(line) }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def self.deliver(role, text)
|
|
391
|
+
return if role.empty?
|
|
392
|
+
|
|
393
|
+
system(*Steward.coord, "msg", "--from", "steward", role, text, out: File::NULL, err: File::NULL)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
Steward.run(ARGV) if __FILE__ == $PROGRAM_NAME
|
data/lib/maf/backup.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# backup.rb - copies of the flow config in the git directory, and maf restore.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
# Backup keeps copies of the files in .maf/ that maf update cannot make again.
|
|
10
|
+
# Each copy is a folder in .git/maf/backup/, so no checkout, merge, or reset
|
|
11
|
+
# changes it. A copy is made only when config.json exists and the files differ
|
|
12
|
+
# from the newest copy.
|
|
13
|
+
class Backup
|
|
14
|
+
FILES = %w[config.json roles.yml workflow.md sandbox.json].freeze
|
|
15
|
+
KEEP = 10
|
|
16
|
+
MISSING = "maf: .maf/config.json is missing. A backup from %<time>s has it. Run: maf restore"
|
|
17
|
+
|
|
18
|
+
def initialize(root)
|
|
19
|
+
@root = root
|
|
20
|
+
@dir = git_dir&.then { |dir| File.join(dir, "maf", "backup") }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save
|
|
24
|
+
files = current
|
|
25
|
+
return if @dir.nil? || !files.key?("config.json") || files == read(latest)
|
|
26
|
+
|
|
27
|
+
write(File.join(@dir, Time.now.utc.strftime("%Y%m%dT%H%M%SZ")), files)
|
|
28
|
+
prune
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def snapshots = @dir ? Dir.glob(File.join(@dir, "*")).select { |path| File.directory?(path) }.sort : []
|
|
32
|
+
def latest = snapshots.last
|
|
33
|
+
def find(name) = snapshots.find { |path| File.basename(path) == name }
|
|
34
|
+
def read(dir) = dir ? FILES.to_h { |name| [name, file(dir, name)] }.compact : {}
|
|
35
|
+
def time(dir) = Time.strptime(File.basename(dir), "%Y%m%dT%H%M%S%z").localtime.strftime("%Y-%m-%d %H:%M")
|
|
36
|
+
|
|
37
|
+
def warn_missing
|
|
38
|
+
maf = File.join(@root, ".maf")
|
|
39
|
+
return if !Dir.exist?(maf) || File.exist?(File.join(maf, "config.json")) || !read(latest).key?("config.json")
|
|
40
|
+
|
|
41
|
+
warn format(MISSING, time: time(latest))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def current = read(File.join(@root, ".maf"))
|
|
47
|
+
def file(dir, name) = File.file?(File.join(dir, name)) ? File.read(File.join(dir, name)) : nil
|
|
48
|
+
|
|
49
|
+
def write(dir, files)
|
|
50
|
+
FileUtils.mkdir_p(dir)
|
|
51
|
+
files.each { |name, text| File.write(File.join(dir, name), text) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prune = snapshots[0...-KEEP].each { |dir| FileUtils.rm_rf(dir) }
|
|
55
|
+
|
|
56
|
+
def git_dir
|
|
57
|
+
out, status = Open3.capture2("git", "-C", @root, "rev-parse", "--git-common-dir", err: File::NULL)
|
|
58
|
+
status.success? ? File.expand_path(out.strip, @root) : nil
|
|
59
|
+
rescue Errno::ENOENT
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -8,8 +8,8 @@ module Maf
|
|
|
8
8
|
# Each step is [planner, method]. The order is the order of the output.
|
|
9
9
|
PLAN_STEPS = [
|
|
10
10
|
%i[layout dirs], %i[scripts shared], %i[scripts coord], %i[scripts dispatcher], %i[scripts vault],
|
|
11
|
-
%i[scripts
|
|
12
|
-
%i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
|
|
11
|
+
%i[scripts steward], %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc],
|
|
12
|
+
%i[text exclude], %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
|
|
13
13
|
%i[scripts doc_graph], %i[git doc_graph_hooks]
|
|
14
14
|
].freeze
|
|
15
15
|
PLANNERS = { layout: LayoutPlanner, scripts: ScriptPlanner, text: TextPlanner, claude: ClaudeSettings,
|
|
@@ -46,6 +46,10 @@ module Maf
|
|
|
46
46
|
script("vault", VAULT_SIGNATURE)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def steward
|
|
50
|
+
script("steward", STEWARD_SIGNATURE)
|
|
51
|
+
end
|
|
52
|
+
|
|
49
53
|
# env.sh puts .maf/bin on PATH. A worker runs `source .maf/env.sh`.
|
|
50
54
|
def env
|
|
51
55
|
script("env.sh", ENV_SIGNATURE, @project.path(MAF_DIR, "env.sh"))
|
data/lib/maf/bootstrap.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Maf
|
|
|
29
29
|
SETUP_AGENT_SIGNATURE = "setup_agent - create a worktree for one agent and launch its harness session."
|
|
30
30
|
DISPATCHER_SIGNATURE = "dispatcher - task board and inbox monitor that starts one-shot agents."
|
|
31
31
|
VAULT_SIGNATURE = "vault - shared knowledge base watcher (graphify + Obsidian + MCP)."
|
|
32
|
+
STEWARD_SIGNATURE = "steward - run the host operations that the leads request."
|
|
32
33
|
DASHBOARD_SIGNATURE = "dashboard - local observability web UI for multi-agent coordination."
|
|
33
34
|
DASHBOARD_PAGE_SIGNATURE = "dashboard.html - page of the maf dashboard."
|
|
34
35
|
ANALYST_SIGNATURE = "analyst - ask a small model for token hints about one dispatched worker."
|
data/lib/maf/budget.rb
CHANGED
data/lib/maf/cli.rb
CHANGED
|
@@ -11,9 +11,11 @@ require_relative "setup_agent"
|
|
|
11
11
|
require_relative "menu"
|
|
12
12
|
require_relative "team"
|
|
13
13
|
require_relative "team_command"
|
|
14
|
+
require_relative "op_command"
|
|
14
15
|
require_relative "worker_control"
|
|
15
16
|
require_relative "worker_list"
|
|
16
17
|
require_relative "untrack"
|
|
18
|
+
require_relative "restore"
|
|
17
19
|
require_relative "vm"
|
|
18
20
|
require_relative "version"
|
|
19
21
|
|
|
@@ -56,9 +58,11 @@ module Maf
|
|
|
56
58
|
"[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
|
|
57
59
|
"start and restart save the session limits of the role",
|
|
58
60
|
"team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
|
|
61
|
+
"op" => "list | approve ID | deny ID the host operations that wait for you (steward)",
|
|
59
62
|
"uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
|
|
60
63
|
"migrate" => "[--check] [--yes] move an old-layout install into .maf/",
|
|
61
64
|
"untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
|
|
65
|
+
"restore" => "[--list] [BACKUP] [--force] put the flow config back from a backup in .git/maf/backup",
|
|
62
66
|
"vm" => "create|login|start|stop|delete|status|shell [ARGS] run the team in a Lima VM (macOS, Linux)",
|
|
63
67
|
"menu" => " interactive mode (also: maf without a command)",
|
|
64
68
|
"guide" => " print the setup guide for an AI agent (install.md)",
|
|
@@ -76,11 +80,26 @@ module Maf
|
|
|
76
80
|
abort "maf: unknown command '#{@command}'. Run: maf help" unless COMMANDS.key?(@command)
|
|
77
81
|
abort "maf: #{Migrate::HINT}" if old_layout?
|
|
78
82
|
|
|
79
|
-
send("run_#{@command}")
|
|
83
|
+
backed_up { send("run_#{@command}") }
|
|
80
84
|
end
|
|
81
85
|
|
|
86
|
+
NO_BACKUP = %w[version guide roles].freeze
|
|
87
|
+
|
|
82
88
|
private
|
|
83
89
|
|
|
90
|
+
def backed_up
|
|
91
|
+
backup = checked_backup unless NO_BACKUP.include?(@command)
|
|
92
|
+
yield
|
|
93
|
+
ensure
|
|
94
|
+
backup&.save
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def checked_backup
|
|
98
|
+
backup = Backup.new(SetupAgent::Project.root)
|
|
99
|
+
backup.warn_missing unless @command == "restore"
|
|
100
|
+
backup.tap(&:save)
|
|
101
|
+
end
|
|
102
|
+
|
|
84
103
|
# Every command except these needs the new layout.
|
|
85
104
|
# maf uninstall --check previews the migration on an old layout.
|
|
86
105
|
def old_layout?
|
|
@@ -101,7 +120,7 @@ module Maf
|
|
|
101
120
|
def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
|
|
102
121
|
def run_update
|
|
103
122
|
Maf.flow(*@args)
|
|
104
|
-
Untrack.
|
|
123
|
+
Untrack.after_update(SetupAgent::Project.root)
|
|
105
124
|
end
|
|
106
125
|
def run_roles = Maf.flow("--list-roles")
|
|
107
126
|
|
|
@@ -114,17 +133,16 @@ module Maf
|
|
|
114
133
|
def run_start = @args.empty? ? SetupAgent.run_here : SetupAgent.run(@args)
|
|
115
134
|
def run_prepare = Prepare.new(@args).run
|
|
116
135
|
def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
|
|
136
|
+
def run_op = OpCommand.new(@args, SetupAgent::Project.root).run
|
|
117
137
|
def run_worker
|
|
118
138
|
return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
|
|
119
139
|
|
|
120
|
-
spec = @args[1] || abort(
|
|
140
|
+
spec = @args[1] || abort(WorkerControl::USAGE)
|
|
121
141
|
limits = RoleLimits.parse(@args)
|
|
122
142
|
WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
|
|
123
143
|
.run(@args[0].to_s)
|
|
124
144
|
end
|
|
125
145
|
|
|
126
|
-
def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
127
|
-
"[--max-context N] [--max-session-runs N] [--cache-window S]"
|
|
128
146
|
def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
|
|
129
147
|
def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
130
148
|
def run_menu = Menu.new.run
|
|
@@ -132,6 +150,7 @@ module Maf
|
|
|
132
150
|
def run_guide = puts(File.read(File.join(Flow::ROOT, "install.md")))
|
|
133
151
|
def run_version = puts("maf #{VERSION}")
|
|
134
152
|
def run_untrack = Untrack.new(SetupAgent::Project.root, @args).run
|
|
153
|
+
def run_restore = Restore.new(SetupAgent::Project.root, @args).run
|
|
135
154
|
def run_migrate = Migrate::Runner.new(["--project", Dir.pwd, *@args]).run
|
|
136
155
|
|
|
137
156
|
def run_agents
|
data/lib/maf/flow/prompt_text.rb
CHANGED
|
@@ -149,6 +149,18 @@ module Maf
|
|
|
149
149
|
A goal pull request uses a merge commit, never a squash.
|
|
150
150
|
TEXT
|
|
151
151
|
|
|
152
|
+
HOST_OPS_RULE = <<~TEXT.strip
|
|
153
|
+
- The sandbox blocks some repairs. Request them from steward: `coord op request <operation> [<argument>]`.
|
|
154
|
+
`worker-restart <worker>`, `worker-retire <worker>`: a worker that is stuck or not seen.
|
|
155
|
+
`worktree-sync <worker>`: a worker branch behind the base branch, or flow files that block it.
|
|
156
|
+
`goal-sync <goal-short-id>`: `coord goal sync` fails with "Operation not permitted".
|
|
157
|
+
`runtime-restore`: the files of `.maf/` are missing in a worktree.
|
|
158
|
+
These wait for the user: `worker-start <worker>`, `worker-model <worker> <model>`,
|
|
159
|
+
`bundle-install` (gems are missing after a Gemfile.lock change).
|
|
160
|
+
steward sends the result to your inbox. Tell the user only if steward refuses or the operation fails.
|
|
161
|
+
If an operation waits, tell the user the `maf op approve <id>` command once. Do not request it again.
|
|
162
|
+
TEXT
|
|
163
|
+
|
|
152
164
|
ARCHITECT_RULES = <<~TEXT.strip
|
|
153
165
|
- Never edit files directly. Dispatch work.
|
|
154
166
|
- Change a goal branch only with `coord land` and `coord goal sync`. Never run `git merge` or `git commit` on it.
|
|
@@ -164,6 +176,7 @@ module Maf
|
|
|
164
176
|
- Before you revise an artifact that a reviewer read, copy it to `<name>.r<round>.md`. Name both files
|
|
165
177
|
in the review task. The reviewer reads `diff -u` of the two files and checks the open findings only.
|
|
166
178
|
#{GRAPH_RULE}
|
|
179
|
+
#{HOST_OPS_RULE}
|
|
167
180
|
TEXT
|
|
168
181
|
|
|
169
182
|
# The architect takes goals from the project manager when that role exists,
|
|
@@ -212,13 +225,16 @@ module Maf
|
|
|
212
225
|
# run the team. Dispatched workers cost no tokens while idle, so the rules
|
|
213
226
|
# scale on backlog, not on cost.
|
|
214
227
|
TEAM_RULES = <<~TEXT.strip
|
|
215
|
-
-
|
|
216
|
-
|
|
228
|
+
- Only the user sets the team budget: `maf team set --max <n> --allow <harness[:model]> ...`.
|
|
229
|
+
If the user gives you a budget in words, give the user that command.
|
|
230
|
+
With a budget, manage the team yourself in dispatch mode. `maf prepare ... --dispatch` starts the worker in the background.
|
|
231
|
+
In the macOS sandbox, maf cannot start it. Give the user the two commands that it prints.
|
|
217
232
|
- Check the team with `maf team`. It shows the budget, each worker, and the tasks by role.
|
|
218
233
|
- Staff the architect first, with `--dispatch`. A goal needs the architect before any other worker.
|
|
219
234
|
A dispatched architect starts on each message. An idle interactive architect reads nothing.
|
|
220
235
|
- Check who runs with `coord who`. A role without a live worker does not read its messages.
|
|
221
|
-
- If coord reports "No worker runs role <role>", add a worker for that role.
|
|
236
|
+
- If coord reports "No worker runs role <role>", add a worker for that role if the budget has a free slot.
|
|
237
|
+
Without a budget, tell the user.
|
|
222
238
|
- If a role has more than three backlog tasks and the budget has a free slot, add a worker for that role.
|
|
223
239
|
- If a role has no tasks and no open goal needs it, retire its extra workers.
|
|
224
240
|
Keep one worker per role that an open goal needs.
|
|
@@ -242,7 +258,8 @@ module Maf
|
|
|
242
258
|
|
|
243
259
|
Rules:
|
|
244
260
|
- Never edit source files. Never create tasks; only the architect creates tasks.
|
|
245
|
-
- Change the team when the user asks
|
|
261
|
+
- Change the team only when the user asks, or within the team budget. Without a budget, `maf prepare` refuses.
|
|
262
|
+
Then give the user the command, or ask the user for a budget.
|
|
246
263
|
Add a worker: `maf prepare <harness> <role>_<n>`.
|
|
247
264
|
Replace a worker: `maf prepare <harness> <role>_<n> --replace <old-role>_<n>`.
|
|
248
265
|
Remove a worker: `maf retire <role>_<n>`.
|
|
@@ -255,6 +272,7 @@ module Maf
|
|
|
255
272
|
- Do not read the project docs in full. Each file that you read stays in your context for the whole session.
|
|
256
273
|
Ask the architect, or start one subagent that returns a summary of at most 300 words.
|
|
257
274
|
- If no report has arrived yet, tell the user and check again with `coord inbox project-manager`.
|
|
275
|
+
#{HOST_OPS_RULE}
|
|
258
276
|
#{STE_RULE}
|
|
259
277
|
#{SUBAGENT_RULE}
|
|
260
278
|
LOOP
|