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.
@@ -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