ace-overseer 0.26.1 → 0.28.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 +138 -0
- data/docs/usage.md +225 -1
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +88 -0
- data/handbook/workflow-instructions/overseer/cold-cycle-acceptance.wf.md +95 -0
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +82 -0
- data/handbook/workflow-instructions/overseer/start.wf.md +79 -0
- data/lib/ace/overseer/atoms/start_contract.rb +128 -0
- data/lib/ace/overseer/cli/commands/start.rb +94 -0
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/event_observation.rb +83 -5
- data/lib/ace/overseer/molecules/local_sources.rb +141 -0
- data/lib/ace/overseer/molecules/project_bundle.rb +119 -0
- data/lib/ace/overseer/molecules/project_root_resolver.rb +66 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +139 -0
- data/lib/ace/overseer/organisms/start_orchestrator.rb +666 -0
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +25 -0
- metadata +17 -12
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/herdr/molecules/bounded_process"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "ace/support/cli/toolchain"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Overseer
|
|
10
|
+
module Molecules
|
|
11
|
+
# A project's own Gemfile/lock and binstub select its ACE version.
|
|
12
|
+
# Never load child project gems into the lab coordinator's process.
|
|
13
|
+
class ProjectBundle
|
|
14
|
+
# A new Herdr shell inherits the server environment, which may differ
|
|
15
|
+
# from this CLI's environment. Clear known Bundler selectors even
|
|
16
|
+
# when the requesting process did not have them set.
|
|
17
|
+
BUNDLE_SELECTORS = %w[
|
|
18
|
+
BUNDLE_ALLOW_OFFLINE_INSTALL BUNDLE_APP_CONFIG BUNDLE_AUTO_INSTALL
|
|
19
|
+
BUNDLE_BIN BUNDLE_BIN_PATH BUNDLE_CACHE_ALL BUNDLE_CACHE_ALL_PLATFORMS
|
|
20
|
+
BUNDLE_CACHE_PATH BUNDLE_CLEAN BUNDLE_CONSOLE BUNDLE_DEPLOYMENT
|
|
21
|
+
BUNDLE_DISABLE_CHECKSUM_VALIDATION BUNDLE_DISABLE_EXEC_LOAD
|
|
22
|
+
BUNDLE_DISABLE_LOCAL_BRANCH_CHECK BUNDLE_DISABLE_LOCAL_REVISION_CHECK
|
|
23
|
+
BUNDLE_DISABLE_SHARED_GEMS BUNDLE_DISABLE_VERSION_CHECK
|
|
24
|
+
BUNDLE_FORCE_RUBY_PLATFORM BUNDLE_FORGET_CLI_OPTIONS BUNDLE_FROZEN
|
|
25
|
+
BUNDLE_GEMFILE BUNDLE_GEM__BUNDLE BUNDLE_GEM__CHANGELOG BUNDLE_GEM__CI
|
|
26
|
+
BUNDLE_GEM__COC BUNDLE_GEM__GITHUB_USERNAME BUNDLE_GEM__LINTER
|
|
27
|
+
BUNDLE_GEM__MIT BUNDLE_GEM__RUBOCOP BUNDLE_GEM__TEST
|
|
28
|
+
BUNDLE_GIT__ALLOW_INSECURE BUNDLE_GLOBAL_GEM_CACHE BUNDLE_IGNORE_CONFIG
|
|
29
|
+
BUNDLE_IGNORE_MESSAGES BUNDLE_INIT_GEMS_RB BUNDLE_INLINE BUNDLE_JOBS
|
|
30
|
+
BUNDLE_LOCKFILE_CHECKSUMS BUNDLE_NO_INSTALL BUNDLE_NO_PRUNE BUNDLE_ONLY
|
|
31
|
+
BUNDLE_PATH BUNDLE_PATH__SYSTEM BUNDLE_PLUGINS BUNDLE_PREFER_PATCH
|
|
32
|
+
BUNDLE_REDIRECT BUNDLE_RETRY BUNDLE_SHEBANG BUNDLE_SILENCE_DEPRECATIONS
|
|
33
|
+
BUNDLE_SILENCE_ROOT_WARNING BUNDLE_SIMULATE_VERSION BUNDLE_SSL_CA_CERT
|
|
34
|
+
BUNDLE_SSL_CLIENT_CERT BUNDLE_SSL_VERIFY_MODE BUNDLE_SYSTEM_BINDIR
|
|
35
|
+
BUNDLE_TIMEOUT BUNDLE_TRUST___POLICY BUNDLE_UPDATE_REQUIRES_ALL_FLAG
|
|
36
|
+
BUNDLE_USER_HOME BUNDLE_USER_CONFIG BUNDLE_USER_CACHE BUNDLE_USER_PLUGIN
|
|
37
|
+
BUNDLE_VERBOSE BUNDLE_VERSION BUNDLE_WITH BUNDLE_WITHOUT
|
|
38
|
+
BUNDLER_SETUP BUNDLER_VERSION
|
|
39
|
+
].freeze
|
|
40
|
+
RUBY_SELECTORS = %w[RUBYOPT RUBYLIB GEM_HOME GEM_PATH ACE_TOOLCHAIN].freeze
|
|
41
|
+
|
|
42
|
+
def self.environment(root, env: ENV)
|
|
43
|
+
if Ace::Support::Cli::Toolchain.installed?
|
|
44
|
+
# Reuse the installed closure; the project checkout is domain data.
|
|
45
|
+
# Do not let a project binstub or Gemfile select source dependencies.
|
|
46
|
+
return Ace::Support::Cli::Toolchain.child_environment.merge(
|
|
47
|
+
"HERDR_PANE" => nil, "HERDR_TAB" => nil,
|
|
48
|
+
"HERDR_PANE_ID" => nil, "HERDR_TAB_ID" => nil
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
known = BUNDLE_SELECTORS + RUBY_SELECTORS
|
|
52
|
+
names = env.keys.grep(/\ABUNDLER?_/) + known +
|
|
53
|
+
(known + %w[PATH]).map { |key| "BUNDLER_ORIG_#{key}" } +
|
|
54
|
+
%w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID]
|
|
55
|
+
names.to_h { |name| [name, nil] }.merge(
|
|
56
|
+
"BUNDLE_GEMFILE" => File.file?(File.join(root, "Gemfile")) ? File.join(root, "Gemfile") : nil,
|
|
57
|
+
"PATH" => [File.join(root, "bin"), env.fetch("PATH", "/usr/bin:/bin")].join(File::PATH_SEPARATOR)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.shell_setup(root, env: ENV)
|
|
62
|
+
if Ace::Support::Cli::Toolchain.installed?
|
|
63
|
+
# A server-created shell may contain selectors absent from this
|
|
64
|
+
# caller. Clear the same known selectors before applying the
|
|
65
|
+
# installed closure; never inherit server-side Bundler injection.
|
|
66
|
+
names = BUNDLE_SELECTORS + BUNDLE_SELECTORS.map { |key| "BUNDLER_ORIG_#{key}" }
|
|
67
|
+
return "unset #{names.map { |name| Shellwords.escape(name) }.join(' ')}; " +
|
|
68
|
+
Ace::Support::Cli::Toolchain.shell_setup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
values = environment(root, env: env).reject { |name, _| %w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID].include?(name) }
|
|
72
|
+
cleared, assigned = values.partition { |_, value| value.nil? }
|
|
73
|
+
"unset #{cleared.map { |name, _| Shellwords.escape(name) }.join(" ")}; export " +
|
|
74
|
+
assigned.map { |name, value| "#{name}=#{Shellwords.escape(value)}" }.join(" ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(timeout_s: 240)
|
|
78
|
+
@timeout_s = timeout_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(root:, workspace:, env: ENV)
|
|
82
|
+
installed = Ace::Support::Cli::Toolchain.installed?
|
|
83
|
+
unless installed
|
|
84
|
+
%w[Gemfile Gemfile.lock].each do |name|
|
|
85
|
+
unless File.file?(File.join(root, name))
|
|
86
|
+
raise Ace::Overseer::Error, "project bundle missing #{name}; prepare this project's own bundle first"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
binary = installed ? Ace::Support::Cli::Toolchain.executable("ace-overseer") : File.join(root, "bin", "ace-overseer")
|
|
91
|
+
command = File.executable?(binary) ? [binary] : ["bundle", "exec", "ace-overseer"]
|
|
92
|
+
command += ["start", "--scope", "project", "--format", "json"]
|
|
93
|
+
child_env = self.class.environment(root, env: env).merge("HERDR_WORKSPACE_ID" => workspace)
|
|
94
|
+
result = Ace::Herdr::Molecules::BoundedProcess.call(command,
|
|
95
|
+
environment: ENV.to_h.merge(child_env).compact, chdir: root,
|
|
96
|
+
timeout_s: @timeout_s, output_limit: 1_048_576)
|
|
97
|
+
raise Ace::Overseer::Error, "project start output exceeded its limit" if result.oversized
|
|
98
|
+
out, err, status = result.stdout, result.stderr, result.status
|
|
99
|
+
report = JSON.parse(out)
|
|
100
|
+
unless report.is_a?(Hash) && report["scope"] == "project" && report["status"].is_a?(String)
|
|
101
|
+
raise Ace::Overseer::Error, "project start returned no project-scope report"
|
|
102
|
+
end
|
|
103
|
+
if !status.success? && %w[started reused ready].include?(report["status"])
|
|
104
|
+
raise Ace::Overseer::Error, "project start exited #{status.exitstatus} despite a ready report"
|
|
105
|
+
end
|
|
106
|
+
report
|
|
107
|
+
rescue Timeout::Error
|
|
108
|
+
raise Ace::Overseer::Error, "project start timed out after #{@timeout_s}s; inspect the existing session before retrying"
|
|
109
|
+
rescue Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => e
|
|
110
|
+
raise Ace::Overseer::Error, "project start outcome is uncertain: #{e.message}"
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
raise Ace::Overseer::Error, "project bundle start returned invalid JSON#{": " + err.strip[0, 300] unless err.to_s.empty?}"
|
|
113
|
+
rescue SystemCallError => e
|
|
114
|
+
raise Ace::Overseer::Error, "project bundle could not start: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Overseer
|
|
5
|
+
module Molecules
|
|
6
|
+
# Canonical project-root resolution for `ace-overseer start`
|
|
7
|
+
# (task 8xs.t.pu0.1): a subdirectory, a symlinked checkout and a
|
|
8
|
+
# linked WORKTREE of the same repository all resolve to the same
|
|
9
|
+
# owning root, so one repository never runs two whole-project
|
|
10
|
+
# overseers. Worktrees map to the main working tree's root through
|
|
11
|
+
# Git's own `--git-common-dir`; the resolved root is realpathed.
|
|
12
|
+
# All Git access runs through an injectable argv runner seam.
|
|
13
|
+
class ProjectRootResolver
|
|
14
|
+
def initialize(runner: nil)
|
|
15
|
+
@runner = runner || method(:run_argv)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param cwd [String] directory the command was invoked in
|
|
19
|
+
# @return [String] canonical project root (realpath)
|
|
20
|
+
# @raise [Ace::Overseer::Error] not a repository, or a bare repo
|
|
21
|
+
def resolve(cwd:)
|
|
22
|
+
toplevel = git_output(cwd, ["git", "-C", cwd, "rev-parse", "--show-toplevel"])
|
|
23
|
+
root = File.realpath(toplevel)
|
|
24
|
+
|
|
25
|
+
common = git_output(cwd, ["git", "-C", root, "rev-parse", "--git-common-dir"])
|
|
26
|
+
common_path = File.expand_path(common, root)
|
|
27
|
+
if File.realpath(common_path) != File.realpath(File.join(root, ".git"))
|
|
28
|
+
# a linked worktree: the main working tree owns the project
|
|
29
|
+
main_root = File.dirname(File.realpath(common_path))
|
|
30
|
+
root = File.realpath(main_root) if File.directory?(main_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
root
|
|
34
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
35
|
+
raise Ace::Overseer::Error, "cannot resolve the project root: '#{cwd}' does not exist"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def git_output(cwd, argv)
|
|
41
|
+
result = @runner.call(argv)
|
|
42
|
+
unless result["status"] == "ok"
|
|
43
|
+
raise Ace::Overseer::Error,
|
|
44
|
+
"cannot resolve the project root under '#{cwd}': run " \
|
|
45
|
+
"ace-overseer start inside the configured project repository"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
line = result["out"].to_s.lines.first.to_s.strip
|
|
49
|
+
raise Ace::Overseer::Error, "git returned no root for '#{cwd}'" if line.empty?
|
|
50
|
+
|
|
51
|
+
line
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_argv(argv)
|
|
55
|
+
out, err, status = Open3.capture3(*argv)
|
|
56
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal"),
|
|
57
|
+
"out" => out, "err" => err}
|
|
58
|
+
rescue Errno::ENOENT
|
|
59
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
60
|
+
rescue SystemCallError => e
|
|
61
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Overseer
|
|
8
|
+
module Molecules
|
|
9
|
+
# Remote PR/repo observation (task 8xs.t.pu0.4): projects configured
|
|
10
|
+
# pull requests through the EXISTING forge provider adapters
|
|
11
|
+
# (Ace::Git::Providers.for — Forgejo and GitHub; no separate HTTP
|
|
12
|
+
# client here). Observed PRs come from the project's own
|
|
13
|
+
# configuration (`observe.remote.pull_requests` in the overseer
|
|
14
|
+
# cascade) — never a scan of every accessible account.
|
|
15
|
+
#
|
|
16
|
+
# Rows follow the standing-observation contract of LocalSources:
|
|
17
|
+
# stable key + SEMANTIC revision (state, head SHA, review/comment
|
|
18
|
+
# identity sets — never poll timestamps or list order), so repeated
|
|
19
|
+
# reads of identical state project identical revisions (zero model
|
|
20
|
+
# turns) while a new HEAD, review, approval, comment, merge, close
|
|
21
|
+
# or reopen revises exactly its row. The row carries the wake
|
|
22
|
+
# payload: forge, PR number/title, event kind, author, link.
|
|
23
|
+
#
|
|
24
|
+
# A failing forge (401/403, rate limit, timeout, malformed payload)
|
|
25
|
+
# degrades ONLY its source — a marker revision keyed by error
|
|
26
|
+
# class wakes once per failure mode, never as an empty healthy
|
|
27
|
+
# list — and recovery re-observes the real state.
|
|
28
|
+
class RemoteSources
|
|
29
|
+
def initialize(provider_factory: nil, timeout: nil)
|
|
30
|
+
@provider_factory = provider_factory || ->(server_name) {
|
|
31
|
+
server = Ace::Git::ServerRegistry.resolve(server_name)
|
|
32
|
+
Ace::Git::Providers.for(server, timeout: timeout)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param pull_requests [Array<Hash>] {"server" =>, "number" =>}
|
|
37
|
+
# entries from the project's observe.remote configuration
|
|
38
|
+
# @return [Hash] {"events" => Array, "sources" => {name => health}}
|
|
39
|
+
def call(pull_requests:)
|
|
40
|
+
sources = {}
|
|
41
|
+
events = []
|
|
42
|
+
|
|
43
|
+
# Validate entries BEFORE grouping: a malformed entry (non-Hash,
|
|
44
|
+
# missing server/number) must degrade its group to a health
|
|
45
|
+
# marker, never crash the whole observation pass.
|
|
46
|
+
valid, invalid = Array(pull_requests).partition do |entry|
|
|
47
|
+
entry.is_a?(Hash) && !entry["server"].to_s.empty? && !entry["number"].to_s.empty?
|
|
48
|
+
end
|
|
49
|
+
invalid.group_by { |entry| entry.is_a?(Hash) ? entry["server"].to_s : "" }.each do |server, entries|
|
|
50
|
+
if server.empty?
|
|
51
|
+
# Non-Hash or server-less entries still surface as health
|
|
52
|
+
# markers, never silently disappear.
|
|
53
|
+
sources["observe.remote"] ||= {"state" => "degraded",
|
|
54
|
+
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
55
|
+
else
|
|
56
|
+
sources["pr:#{server}"] ||= {"state" => "degraded",
|
|
57
|
+
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
valid.group_by { |entry| entry["server"] }.each do |server, entries|
|
|
62
|
+
collect(sources, events, "pr:#{server}") do
|
|
63
|
+
entries.flat_map { |entry| project(server, entry["number"]) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
{"events" => events, "sources" => sources}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def collect(sources, events, name)
|
|
73
|
+
rows = yield
|
|
74
|
+
sources[name] = {"status" => "ok"}
|
|
75
|
+
events.concat(rows)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
sources[name] = {"status" => "degraded", "error" => "#{e.class}: #{e.message}"}
|
|
78
|
+
events << {"key" => "source:#{name}",
|
|
79
|
+
"revision" => Digest::SHA256.hexdigest("degraded:#{e.class}"),
|
|
80
|
+
"type" => "source-health", "status" => "degraded",
|
|
81
|
+
"error" => "#{e.class}: #{e.message}"}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def project(server, number)
|
|
85
|
+
provider = @provider_factory.call(server)
|
|
86
|
+
pr = provider.pull_request(number: number)
|
|
87
|
+
return [] if pr.nil?
|
|
88
|
+
|
|
89
|
+
rows = [pr_row(server, pr)]
|
|
90
|
+
evidence = provider.pull_request_review_evidence(number: number, expected_head: pr.head_sha)
|
|
91
|
+
rows << activity_row(server, pr, evidence) if evidence
|
|
92
|
+
rows
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The PR's own lifecycle: new HEAD (head_sha), merge, close,
|
|
96
|
+
# reopen, title/draft changes. Read fresh before any merge
|
|
97
|
+
# decision — remote data stays data.
|
|
98
|
+
def pr_row(server, pr)
|
|
99
|
+
payload = {"number" => pr.number, "state" => pr.state.to_s,
|
|
100
|
+
"head_sha" => pr.head_sha, "draft" => pr.draft,
|
|
101
|
+
"title" => pr.title, "merged_at" => pr.merged_at,
|
|
102
|
+
"merge_commit_sha" => pr.merge_commit_sha}
|
|
103
|
+
{"key" => "pr:#{server}:#{pr.number}", "revision" => digest(payload),
|
|
104
|
+
"type" => "pr", "forge" => server, "number" => pr.number,
|
|
105
|
+
"title" => pr.title, "state" => pr.state.to_s, "head_sha" => pr.head_sha,
|
|
106
|
+
"author" => pr.author, "url" => pr.url}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Review/comment/approval activity: the revision covers the
|
|
110
|
+
# SORTED identity set (id, author, state, head binding) of every
|
|
111
|
+
# review and comment — re-ordered payloads and volatile counters
|
|
112
|
+
# never revise; a new verdict, approval or comment revises once.
|
|
113
|
+
# The row's summary names the latest item for the wake payload.
|
|
114
|
+
def activity_row(server, pr, evidence)
|
|
115
|
+
reviews = Array(evidence.reviews).map { |review|
|
|
116
|
+
{"id" => review.id.to_s, "author" => review.author, "state" => review.state.to_s,
|
|
117
|
+
"head_sha" => review.head_sha, "kind" => "review"}
|
|
118
|
+
}
|
|
119
|
+
comments = Array(evidence.comments).map { |comment|
|
|
120
|
+
{"id" => comment.id.to_s, "author" => comment.author, "state" => nil,
|
|
121
|
+
"head_sha" => comment.head_sha, "kind" => "comment"}
|
|
122
|
+
}
|
|
123
|
+
items = (reviews + comments).sort_by { |item| item["id"] }
|
|
124
|
+
latest = items.last
|
|
125
|
+
|
|
126
|
+
{"key" => "pr-activity:#{server}:#{pr.number}", "revision" => digest(items),
|
|
127
|
+
"type" => "pr-activity", "forge" => server, "number" => pr.number,
|
|
128
|
+
"title" => pr.title, "event_kind" => latest && latest["kind"],
|
|
129
|
+
"event_id" => latest && latest["id"], "author" => latest && latest["author"],
|
|
130
|
+
"url" => pr.url, "items" => items.size}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def digest(payload)
|
|
134
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|