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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# op_command.rb - list, approve, or deny the host operations that wait for the user.
|
|
4
|
+
#
|
|
5
|
+
# maf op list | approve ID | deny ID
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require_relative "team"
|
|
8
|
+
|
|
9
|
+
module Maf
|
|
10
|
+
# OpCommand passes the command to .maf/bin/steward. Only the user runs it:
|
|
11
|
+
# an approved operation runs on the host, outside the sandbox.
|
|
12
|
+
class OpCommand
|
|
13
|
+
USAGE = "usage: maf op list | approve ID | deny ID"
|
|
14
|
+
AGENT = "maf: only the user approves host operations. Ask the user to run this command in a terminal."
|
|
15
|
+
|
|
16
|
+
def initialize(argv, root)
|
|
17
|
+
@argv = argv
|
|
18
|
+
@root = root
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
abort AGENT if Team.agent?
|
|
23
|
+
abort USAGE unless %w[list approve deny].include?(@argv.first)
|
|
24
|
+
|
|
25
|
+
exec(RbConfig.ruby, File.join(@root, ".maf", "bin", "steward"), *@argv)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/maf/restore.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# restore.rb - maf restore: put the flow config back from a backup.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "backup"
|
|
6
|
+
|
|
7
|
+
module Maf
|
|
8
|
+
# Restore copies the files of a backup into .maf/. It keeps a file that
|
|
9
|
+
# exists, unless --force is given. Then it runs maf update.
|
|
10
|
+
class Restore
|
|
11
|
+
USAGE = "usage: maf restore [--list] [BACKUP] [--force]"
|
|
12
|
+
|
|
13
|
+
def initialize(root, argv)
|
|
14
|
+
@root = root
|
|
15
|
+
@backup = Backup.new(root)
|
|
16
|
+
@list = argv.include?("--list")
|
|
17
|
+
@force = argv.include?("--force")
|
|
18
|
+
@name = argv.find { |arg| !arg.start_with?("-") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
return list if @list
|
|
23
|
+
|
|
24
|
+
dir = @name ? @backup.find(@name) : @backup.latest
|
|
25
|
+
abort(@name ? "maf: no backup #{@name}. Run: maf restore --list" : "maf: no backup in #{@root}") unless dir
|
|
26
|
+
restore(dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def list
|
|
32
|
+
return puts("No backup yet. maf makes one when a maf command runs.") if @backup.snapshots.empty?
|
|
33
|
+
|
|
34
|
+
@backup.snapshots.reverse_each { |dir| puts line(dir) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def line(dir)
|
|
38
|
+
files = @backup.read(dir)
|
|
39
|
+
agents = JSON.parse(files.fetch("config.json", "{}")).fetch("agents", []).size
|
|
40
|
+
"#{File.basename(dir)} #{@backup.time(dir)} #{agents} agents #{files.keys.join(", ")}"
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
"#{File.basename(dir)} #{@backup.time(dir)} #{files.keys.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def restore(dir)
|
|
46
|
+
puts "Backup #{File.basename(dir)} (#{@backup.time(dir)}):"
|
|
47
|
+
written = @backup.read(dir).select { |name, text| put(name, text) }
|
|
48
|
+
return puts("Nothing to restore. --force replaces the files that exist.") if written.empty?
|
|
49
|
+
|
|
50
|
+
Dir.chdir(@root) { Maf.flow } if written.key?("config.json")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def put(name, text)
|
|
54
|
+
path = File.join(@root, ".maf", name)
|
|
55
|
+
return puts(" keep .maf/#{name} (exists; --force replaces it)") || false if File.exist?(path) && !@force
|
|
56
|
+
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, text)
|
|
59
|
+
puts(" restore .maf/#{name}") || true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# session_records.rb - the live sessions that maf start registered.
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "shared/processes"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
# SessionRecords reads .maf/coordination/sessions/*.maf.json. The session
|
|
10
|
+
# guard writes one record for each maf start. A record is live while its pid
|
|
11
|
+
# runs and the process started before the record was written, so a reused
|
|
12
|
+
# pid does not count.
|
|
13
|
+
class SessionRecords
|
|
14
|
+
LSTART = "%a %b %d %H:%M:%S %Y %z"
|
|
15
|
+
|
|
16
|
+
def initialize(coord_dir)
|
|
17
|
+
@dir = File.join(coord_dir, "sessions")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def live = Dir.glob(File.join(@dir, "*.maf.json")).filter_map { |path| entry(path) }
|
|
21
|
+
def find(worker) = live.find { |record| record["worker"] == worker }
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def entry(path)
|
|
26
|
+
record = JSON.parse(File.read(path))
|
|
27
|
+
started = Shared::Processes.started_at(record["pid"])
|
|
28
|
+
record.merge("started" => started) if live?(record["pid"], started, File.mtime(path))
|
|
29
|
+
rescue JSON::ParserError, SystemCallError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def live?(pid, started, registered)
|
|
34
|
+
Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Maf
|
|
6
|
+
module SetupAgent
|
|
7
|
+
# Steward starts the steward daemon of the project. It runs before the
|
|
8
|
+
# agent variables are set, so steward does not run as an agent.
|
|
9
|
+
module Steward
|
|
10
|
+
def self.ensure(root)
|
|
11
|
+
script = File.join(root, ".maf", "bin", "steward")
|
|
12
|
+
return if ENV["STEWARD_SKIP"] || !File.exist?(script)
|
|
13
|
+
|
|
14
|
+
system(RbConfig.ruby, script, chdir: root, out: File::NULL, err: File::NULL)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/maf/setup_agent.rb
CHANGED
|
@@ -81,6 +81,7 @@ module Maf
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def self.launch(args, _worktree)
|
|
84
|
+
Steward.ensure(Project.root)
|
|
84
85
|
identify(args)
|
|
85
86
|
return Dispatch.launch(args) if args.dispatch
|
|
86
87
|
|
|
@@ -106,4 +107,5 @@ require_relative "setup_agent/worktree"
|
|
|
106
107
|
require_relative "setup_agent/launcher"
|
|
107
108
|
require_relative "setup_agent/project"
|
|
108
109
|
require_relative "setup_agent/runtime_hooks"
|
|
110
|
+
require_relative "setup_agent/steward"
|
|
109
111
|
require_relative "../../assets/harness-hooks/session-guard"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require_relative "harness"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
# FlowFiles finds the files of the flow in a worktree. A branch from before
|
|
11
|
+
# maf untrack still tracks them, and maf rewrites them in each worktree.
|
|
12
|
+
module FlowFiles
|
|
13
|
+
def self.path?(path) = path.start_with?(".maf/") || Harness.all.flat_map(&:runtime_files).include?(path)
|
|
14
|
+
|
|
15
|
+
# Only a change that a merge of TARGET deletes is discarded. Returns true when no change is left.
|
|
16
|
+
def self.discard(dir, target)
|
|
17
|
+
paths = changed(dir)
|
|
18
|
+
return true if paths.empty?
|
|
19
|
+
return false unless discardable?(dir, target, paths)
|
|
20
|
+
|
|
21
|
+
system("git", "-C", dir, "checkout", "-q", "HEAD", "--", *paths)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.discardable?(dir, target, paths)
|
|
25
|
+
paths.all? { |path| path?(path) } && git(dir, "ls-tree", "-r", "--name-only", target, "--", *paths).empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.tracked?(dir) = !git(dir, "ls-files", "--", ".maf").empty?
|
|
29
|
+
def self.changed(dir) = git(dir, "diff", "--name-only", "HEAD")
|
|
30
|
+
def self.git(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:readlines).map(&:chomp)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -24,11 +24,12 @@ module Maf
|
|
|
24
24
|
|
|
25
25
|
attr_reader :root
|
|
26
26
|
|
|
27
|
-
def initialize(harnesses, root, coord_dir,
|
|
27
|
+
def initialize(harnesses, root, coord_dir, policy)
|
|
28
28
|
@harness = HarnessFiles.new(harnesses, ->(paths) { real(paths) })
|
|
29
29
|
@root = root
|
|
30
30
|
@coord_dir = coord_dir || File.join(root, ".maf", "coordination")
|
|
31
|
-
@extra =
|
|
31
|
+
@extra = Array(policy["write"]).map { |path| File.expand_path(path, root) }
|
|
32
|
+
@app_paths = Array(policy["launch_services"]).map { |path| File.expand_path(path, root) }
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def dirs = @dirs ||= real([@root, @coord_dir, *@extra, ENV.fetch("TMPDIR", nil), *COMMON])
|
|
@@ -45,6 +46,13 @@ module Maf
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def existing = dirs.select { |dir| File.exist?(dir) }
|
|
49
|
+
|
|
50
|
+
def apps = @apps ||= real(@app_paths).select { |path| File.exist?(path) }.each { |path| fixed_app!(path) }
|
|
51
|
+
|
|
52
|
+
def fixed_app!(path)
|
|
53
|
+
writable?(path) && abort("maf: #{path} in launch_services is in a folder that the agent can change.")
|
|
54
|
+
end
|
|
55
|
+
|
|
48
56
|
def sockets = real([@root, @coord_dir])
|
|
49
57
|
|
|
50
58
|
def protected
|
|
@@ -11,18 +11,23 @@ module Maf
|
|
|
11
11
|
# Seatbelt runs a command in a macOS sandbox-exec profile.
|
|
12
12
|
module Seatbelt
|
|
13
13
|
COMMAND = "/usr/bin/sandbox-exec"
|
|
14
|
+
MARKER = "MAF_SEATBELT"
|
|
14
15
|
DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
|
|
15
16
|
'(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
|
|
16
17
|
DNS = '(path-literal "/private/var/run/mDNSResponder")'
|
|
17
18
|
TMP_PREFIX = "maf-sandbox-"
|
|
18
19
|
SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
|
|
19
20
|
"(deny appleevent-send)\n"
|
|
21
|
+
LAUNCH_SERVICES = "com.apple.coreservices.launchservicesd"
|
|
20
22
|
|
|
21
23
|
def self.wrap(cmd, paths)
|
|
22
24
|
tmp = private_tmp(paths.root)
|
|
23
|
-
[COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
|
|
25
|
+
[COMMAND, "-p", profile(paths, tmp), "env", "#{MARKER}=1", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache",
|
|
26
|
+
*cmd]
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
def self.inside? = ENV[MARKER] == "1"
|
|
30
|
+
|
|
26
31
|
def self.available? = File.executable?(COMMAND)
|
|
27
32
|
def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
|
|
28
33
|
|
|
@@ -48,7 +53,23 @@ module Maf
|
|
|
48
53
|
|
|
49
54
|
def self.profile(paths, tmp)
|
|
50
55
|
"(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
|
|
51
|
-
"#{sockets([*paths.sockets, tmp])}#{SERVICES}"
|
|
56
|
+
"#{sockets([*paths.sockets, tmp])}#{SERVICES}#{launch_services(paths.apps)}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A later rule wins, so this exception comes after the denial.
|
|
60
|
+
def self.launch_services(apps)
|
|
61
|
+
programs = apps.flat_map { |app| programs(app) }.uniq
|
|
62
|
+
return "" if programs.empty?
|
|
63
|
+
|
|
64
|
+
filters = programs.map { |path| "(process-path #{quote(path)})" }
|
|
65
|
+
"(allow mach-lookup (require-all (global-name \"#{LAUNCH_SERVICES}\")\n " \
|
|
66
|
+
"(require-any #{filters.join("\n ")})))\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.programs(app)
|
|
70
|
+
return [app] unless File.directory?(app)
|
|
71
|
+
|
|
72
|
+
Dir.glob(File.join(app, "**", "Contents", "MacOS", "*")).select { |path| File.file?(path) }
|
|
52
73
|
end
|
|
53
74
|
|
|
54
75
|
def self.writes(paths)
|
data/lib/maf/shared/sandbox.rb
CHANGED
|
@@ -18,6 +18,8 @@ module Maf
|
|
|
18
18
|
BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
|
|
19
19
|
MODES = ["off", "auto", *BACKENDS.keys].freeze
|
|
20
20
|
POLICY = File.join(".maf", "sandbox.json")
|
|
21
|
+
NESTED = "maf: this process runs in the macOS sandbox, and macOS cannot start a second sandbox in it. " \
|
|
22
|
+
"Start the worker from your terminal: cd <worktree> && maf start"
|
|
21
23
|
|
|
22
24
|
def self.policy
|
|
23
25
|
path = File.join(Project.root, POLICY)
|
|
@@ -40,9 +42,13 @@ module Maf
|
|
|
40
42
|
def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
41
43
|
policy = self.policy
|
|
42
44
|
mode = mode(policy)
|
|
45
|
+
refuse_nested!(mode)
|
|
43
46
|
mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
|
|
44
47
|
end
|
|
45
48
|
|
|
49
|
+
def self.nested?(mode = self.mode) = mode == "seatbelt" && Seatbelt.inside?
|
|
50
|
+
def self.refuse_nested!(mode = self.mode) = nested?(mode) && abort(NESTED)
|
|
51
|
+
|
|
46
52
|
def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
47
53
|
links = protected_links(harness, coord_dir)
|
|
48
54
|
yield
|
|
@@ -68,7 +74,7 @@ module Maf
|
|
|
68
74
|
|
|
69
75
|
def self.paths(harness, policy, coord_dir)
|
|
70
76
|
protect_policy
|
|
71
|
-
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir,
|
|
77
|
+
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, policy)
|
|
72
78
|
end
|
|
73
79
|
|
|
74
80
|
def self.project_harnesses = Project.manifest.fetch("agents", []).filter_map { |a| Harness[a["harness"]] }
|
data/lib/maf/team.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Maf
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def self.notify(root, text) = coord(root, "msg", "--from", "maf", "architect", text)
|
|
27
|
+
def self.agent? = !ENV["COORD_ROLE"].to_s.empty?
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Prepare makes a worker ready to start. With --replace, it retires the
|
|
@@ -35,6 +36,8 @@ module Maf
|
|
|
35
36
|
# The architect never talks to the user. A dispatched architect gets fresh
|
|
36
37
|
# sessions with a handoff note, so its context stays small.
|
|
37
38
|
DISPATCH_DEFAULT = %w[architect].freeze
|
|
39
|
+
NO_BUDGET = "maf: an agent cannot change the team without a budget. Ask the user to set one " \
|
|
40
|
+
"(maf team set --max N), or to run this command in a terminal."
|
|
38
41
|
|
|
39
42
|
def self.mode_args(argv)
|
|
40
43
|
return argv - ["--interactive"] if argv.include?("--interactive")
|
|
@@ -52,7 +55,7 @@ module Maf
|
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
def run
|
|
55
|
-
|
|
58
|
+
check_budget
|
|
56
59
|
retire = (Retire.new(@root, @replace).tap(&:check!) if @replace)
|
|
57
60
|
Dir.chdir(@root) { prepare }
|
|
58
61
|
retire.run if retire && Workers.id(@replace) != @args.worker
|
|
@@ -61,11 +64,16 @@ module Maf
|
|
|
61
64
|
|
|
62
65
|
def announce
|
|
63
66
|
Team.notify(@root, "Team change: worker #{@args.worker} (#{@args.harness}) joined as #{@args.role}.")
|
|
64
|
-
@args.dispatch ? start_dispatcher : print_next_steps
|
|
67
|
+
@args.dispatch && !Shared::Sandbox.nested? ? start_dispatcher : print_next_steps
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
private
|
|
68
71
|
|
|
72
|
+
def check_budget
|
|
73
|
+
abort NO_BUDGET if Team.agent? && !@budget.limited?
|
|
74
|
+
@budget.check!(@args.harness, @args.model, @budget.count(workers_after))
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
def take_replace(argv)
|
|
70
78
|
index = argv.index("--replace") or return nil
|
|
71
79
|
argv.slice!(index, 2)[1] || abort("maf: --replace needs a worker, for example backend-developer_2")
|
data/lib/maf/team_command.rb
CHANGED
|
@@ -18,7 +18,14 @@ module Maf
|
|
|
18
18
|
@root = root
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
AGENT_SET = "maf: only the user sets the team budget. Give the user this command to run in a terminal."
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
return show unless @argv.first == "set"
|
|
25
|
+
|
|
26
|
+
abort AGENT_SET if Team.agent?
|
|
27
|
+
set(@argv.drop(1))
|
|
28
|
+
end
|
|
22
29
|
|
|
23
30
|
private
|
|
24
31
|
|
|
@@ -9,6 +9,7 @@ module Maf
|
|
|
9
9
|
".maf/bin/dashboard.html" => Bootstrap::DASHBOARD_PAGE_SIGNATURE,
|
|
10
10
|
".maf/bin/analyst" => Bootstrap::ANALYST_SIGNATURE,
|
|
11
11
|
".maf/bin/vault" => Bootstrap::VAULT_SIGNATURE,
|
|
12
|
+
".maf/bin/steward" => Bootstrap::STEWARD_SIGNATURE,
|
|
12
13
|
".maf/bin/doc-graph-refresh" => Bootstrap::DOC_GRAPH_SIGNATURE,
|
|
13
14
|
".maf/env.sh" => Bootstrap::ENV_SIGNATURE,
|
|
14
15
|
Bootstrap::OPENCODE_PLUGIN => Bootstrap::OPENCODE_BOARD_WATCH_SIGNATURE }.freeze
|
data/lib/maf/untrack.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# untrack.rb - move an install that git tracks to the local layout.
|
|
4
4
|
require "json"
|
|
5
|
+
require "rbconfig"
|
|
5
6
|
require_relative "uninstall"
|
|
6
7
|
|
|
7
8
|
module Maf
|
|
@@ -23,6 +24,21 @@ module Maf
|
|
|
23
24
|
|
|
24
25
|
def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
|
|
25
26
|
|
|
27
|
+
def self.after_update(root)
|
|
28
|
+
warn_tracked(root)
|
|
29
|
+
migrate_goals(root)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.migrate_goals(root)
|
|
33
|
+
coord = File.join(root, ".maf", "bin", "coord")
|
|
34
|
+
system(RbConfig.ruby, coord, "goal", "migrate", chdir: root) if File.exist?(coord)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
|
|
38
|
+
"A branch that still tracks the flow files can delete them in a checkout or a merge.",
|
|
39
|
+
"After you push the commit, maf start fast-forwards each worker, and maf update syncs each goal.",
|
|
40
|
+
"maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
|
|
41
|
+
|
|
26
42
|
def run
|
|
27
43
|
steps = plan
|
|
28
44
|
return puts("maf: git tracks no file of the flow in #{@root}.") if steps.empty?
|
|
@@ -55,7 +71,7 @@ module Maf
|
|
|
55
71
|
abort "maf: cancelled" unless @yes || confirmed?
|
|
56
72
|
steps.each(&:run)
|
|
57
73
|
Dir.chdir(@root) { Maf.flow }
|
|
58
|
-
puts
|
|
74
|
+
puts DONE
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
def confirmed?
|
data/lib/maf/version.rb
CHANGED
data/lib/maf/worker_control.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "workers"
|
|
|
8
8
|
require_relative "retire"
|
|
9
9
|
require_relative "role_limits"
|
|
10
10
|
require_relative "shared/processes"
|
|
11
|
+
require_relative "session_records"
|
|
11
12
|
|
|
12
13
|
module Maf
|
|
13
14
|
# WorkerControl stops, starts, or restarts one worker. Each action is
|
|
@@ -17,8 +18,12 @@ module Maf
|
|
|
17
18
|
# only when the session is idle. maf then prints the start command.
|
|
18
19
|
class WorkerControl
|
|
19
20
|
ACTIONS = %w[status stop start restart].freeze
|
|
21
|
+
USAGE = "usage: maf worker list | #{ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
|
|
22
|
+
"[--max-context N] [--max-session-runs N] [--cache-window S]".freeze
|
|
20
23
|
MAF = File.expand_path("../../exe/maf", __dir__)
|
|
21
24
|
IDLE = 60
|
|
25
|
+
STUCK = "maf: %<worker>s did not stop after TERM. Its terminal may be closed. " \
|
|
26
|
+
"Stop it with: pkill -9 -P %<pid>s; kill -9 %<pid>s"
|
|
22
27
|
|
|
23
28
|
# force stops an interactive session also when maf cannot tell if it is idle.
|
|
24
29
|
# limits are session limits of the role. start and restart save them first.
|
|
@@ -83,8 +88,12 @@ module Maf
|
|
|
83
88
|
abort "maf: #{@worker} is in a turn. Try again when the session is idle." if busy?
|
|
84
89
|
abort "maf: maf cannot tell if #{@worker} is idle. Stop it in its terminal, or add --force." if unknown?
|
|
85
90
|
|
|
91
|
+
terminate_session
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def terminate_session
|
|
86
95
|
RunningProcesses.terminate(pid)
|
|
87
|
-
RunningProcesses.wait_for_exit(pid, timeout: 10)
|
|
96
|
+
abort format(STUCK, worker: @worker, pid: pid) unless RunningProcesses.wait_for_exit(pid, timeout: 10)
|
|
88
97
|
puts "Stopped #{@worker}. To start it again: #{start_hint}"
|
|
89
98
|
end
|
|
90
99
|
|
|
@@ -95,7 +104,8 @@ module Maf
|
|
|
95
104
|
|
|
96
105
|
def start_hint = "cd #{@entry["dir"]} && maf start"
|
|
97
106
|
def coord_dir = File.join(@root, ".maf", "coordination")
|
|
98
|
-
def presence =
|
|
107
|
+
def presence = @presence ||= SessionRecords.new(coord_dir).find(@worker) || presence_file
|
|
108
|
+
def presence_file = read(File.join(coord_dir, "presence", "#{@worker}.json"))
|
|
99
109
|
def pid = presence["pid"].to_i
|
|
100
110
|
def live? = Shared::Processes.live?(presence)
|
|
101
111
|
def ours? = Shared::Processes.same?(pid, presence["started"])
|
data/lib/maf/worker_list.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "workers"
|
|
7
7
|
require_relative "shared/processes"
|
|
8
|
+
require_relative "session_records"
|
|
8
9
|
|
|
9
10
|
module Maf
|
|
10
11
|
# WorkerList shows each running worker process of the project. A worker
|
|
@@ -12,7 +13,6 @@ module Maf
|
|
|
12
13
|
class WorkerList
|
|
13
14
|
Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
|
|
14
15
|
HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
|
|
15
|
-
LSTART = "%a %b %d %H:%M:%S %Y %z"
|
|
16
16
|
|
|
17
17
|
def initialize(root)
|
|
18
18
|
@root = root
|
|
@@ -31,18 +31,7 @@ module Maf
|
|
|
31
31
|
def coord_dir = File.join(@root, ".maf", "coordination")
|
|
32
32
|
def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
|
|
33
33
|
|
|
34
|
-
def sessions
|
|
35
|
-
records("sessions", "*.maf.json").filter_map do |path, record|
|
|
36
|
-
row(record, "session") if session_live?(record["pid"], File.mtime(path))
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def session_live?(pid, registered)
|
|
41
|
-
started = Shared::Processes.started_at(pid)
|
|
42
|
-
Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
|
|
43
|
-
rescue ArgumentError
|
|
44
|
-
false
|
|
45
|
-
end
|
|
34
|
+
def sessions = SessionRecords.new(coord_dir).live.map { |record| row(record, "session") }
|
|
46
35
|
|
|
47
36
|
def dispatchers
|
|
48
37
|
records("presence", "*.json").filter_map do |_, entry|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: maf
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dominik Alberski
|
|
@@ -53,12 +53,14 @@ files:
|
|
|
53
53
|
- assets/harness-hooks/next-task.rb
|
|
54
54
|
- assets/harness-hooks/session-guard.rb
|
|
55
55
|
- assets/lima/maf.yaml.erb
|
|
56
|
+
- assets/steward
|
|
56
57
|
- assets/taskrc.append
|
|
57
58
|
- assets/vault
|
|
58
59
|
- assets/worktree-env.example.rb
|
|
59
60
|
- exe/maf
|
|
60
61
|
- install.md
|
|
61
62
|
- lib/maf.rb
|
|
63
|
+
- lib/maf/backup.rb
|
|
62
64
|
- lib/maf/bootstrap.rb
|
|
63
65
|
- lib/maf/bootstrap/claude_settings.rb
|
|
64
66
|
- lib/maf/bootstrap/dependencies.rb
|
|
@@ -114,9 +116,12 @@ files:
|
|
|
114
116
|
- lib/maf/migrate/role_files.rb
|
|
115
117
|
- lib/maf/migrate/runner.rb
|
|
116
118
|
- lib/maf/migrate/worktrees.rb
|
|
119
|
+
- lib/maf/op_command.rb
|
|
117
120
|
- lib/maf/prompt.rb
|
|
121
|
+
- lib/maf/restore.rb
|
|
118
122
|
- lib/maf/retire.rb
|
|
119
123
|
- lib/maf/role_limits.rb
|
|
124
|
+
- lib/maf/session_records.rb
|
|
120
125
|
- lib/maf/setup_agent.rb
|
|
121
126
|
- lib/maf/setup_agent/args.rb
|
|
122
127
|
- lib/maf/setup_agent/dispatch.rb
|
|
@@ -126,7 +131,9 @@ files:
|
|
|
126
131
|
- lib/maf/setup_agent/pty_relay.rb
|
|
127
132
|
- lib/maf/setup_agent/role_file.rb
|
|
128
133
|
- lib/maf/setup_agent/runtime_hooks.rb
|
|
134
|
+
- lib/maf/setup_agent/steward.rb
|
|
129
135
|
- lib/maf/setup_agent/worktree.rb
|
|
136
|
+
- lib/maf/shared/flow_files.rb
|
|
130
137
|
- lib/maf/shared/git_exclude.rb
|
|
131
138
|
- lib/maf/shared/git_identity.rb
|
|
132
139
|
- lib/maf/shared/harness.rb
|