ace-overseer 0.26.0 → 0.27.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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Overseer
5
+ module Atoms
6
+ # Pure validation and normalization of the `start` contract
7
+ # (task 8xs.t.pu0.1): the `.ace/overseer/config.yml` cascade must
8
+ # carry an EXPLICIT start section — `scope: project|lab`, a
9
+ # `profile` reference into the same document's `profiles` map and
10
+ # an `instructions` source (wfi:// or an absolute file path).
11
+ # Nothing is guessed: a missing or ambiguous section is an error
12
+ # BEFORE any session is created, and no interim default model is
13
+ # applied anywhere. Pure — no IO, value-free messages.
14
+ class StartContract
15
+ SCOPES = %w[project lab].freeze
16
+ SECTION_KEYS = %w[scope profile instructions].freeze
17
+ PROFILE_KEYS = %w[harness model thinking].freeze
18
+ INSTRUCTION_PROTOCOLS = %w[wfi:// prompt:// tmpl://].freeze
19
+
20
+ # @param config [Hash] resolved overseer config (cascade output)
21
+ # @return [Hash] normalized start plan fragment:
22
+ # {scope:, profile_name:, profile: {harness:, model:,
23
+ # thinking:}, instructions:}
24
+ # @raise [Ace::Overseer::Error] missing/ambiguous/invalid start
25
+ # configuration
26
+ def self.normalize(config)
27
+ start = config["start"]
28
+ unless start.is_a?(Hash)
29
+ raise Ace::Overseer::Error,
30
+ "ace-overseer start requires an explicit 'start' section in " \
31
+ ".ace/overseer/config.yml (scope, profile, instructions); " \
32
+ "none is configured for this directory"
33
+ end
34
+
35
+ unknown = start.keys - SECTION_KEYS
36
+ unless unknown.empty?
37
+ raise Ace::Overseer::Error,
38
+ "start section has unknown key '#{unknown.sort.first}' " \
39
+ "(allowed: #{SECTION_KEYS.join(', ')})"
40
+ end
41
+
42
+ scope = required(start, "scope")
43
+ unless SCOPES.include?(scope)
44
+ raise Ace::Overseer::Error,
45
+ "start scope must be one of: #{SCOPES.join(', ')} (got '#{scope}')"
46
+ end
47
+
48
+ profile_name = required(start, "profile")
49
+ profile = profile_for(config, profile_name)
50
+
51
+ instructions = required(start, "instructions")
52
+ unless INSTRUCTION_PROTOCOLS.any? { |prefix| instructions.start_with?(prefix) } ||
53
+ instructions.start_with?("/")
54
+ raise Ace::Overseer::Error,
55
+ "start instructions must be a wfi:// (or prompt://, tmpl://) " \
56
+ "source or an absolute file path"
57
+ end
58
+
59
+ {"scope" => scope, "profile_name" => profile_name,
60
+ "profile" => profile, "instructions" => instructions}
61
+ end
62
+
63
+ # The pane/tab label that OWNS this project's overseer session:
64
+ # derived from the project root so every start from the same
65
+ # repository (subdirectory, symlink, worktree) converges on the
66
+ # same owner identity.
67
+ def self.session_label(project_root)
68
+ "overseer-#{File.basename(project_root)}"
69
+ end
70
+
71
+ def self.profile_for(config, profile_name)
72
+ profiles = config["profiles"]
73
+ unless profiles.is_a?(Hash) && profiles[profile_name].is_a?(Hash)
74
+ raise Ace::Overseer::Error,
75
+ "start profile '#{profile_name}' is not configured; declare it " \
76
+ "under 'profiles' in .ace/overseer/config.yml"
77
+ end
78
+
79
+ raw = profiles[profile_name]
80
+ unknown = raw.keys - PROFILE_KEYS
81
+ unless unknown.empty?
82
+ raise Ace::Overseer::Error,
83
+ "profile '#{profile_name}' has unknown key '#{unknown.sort.first}' " \
84
+ "(allowed: #{PROFILE_KEYS.join(', ')})"
85
+ end
86
+
87
+ harness = required_value(raw, "harness", "profile '#{profile_name}'")
88
+ model = required_value(raw, "model", "profile '#{profile_name}'")
89
+ thinking = raw["thinking"]
90
+
91
+ {"harness" => harness, "model" => model, "thinking" => thinking}
92
+ end
93
+
94
+ def self.required(start, key)
95
+ value = start[key]
96
+ unless value.is_a?(String) && !value.strip.empty?
97
+ raise Ace::Overseer::Error,
98
+ "start section key '#{key}' is required and must be a non-empty string"
99
+ end
100
+
101
+ value.strip
102
+ end
103
+
104
+ def self.required_value(raw, key, owner)
105
+ value = raw[key]
106
+ unless value.is_a?(String) && !value.strip.empty?
107
+ raise Ace::Overseer::Error, "#{owner} key '#{key}' is required"
108
+ end
109
+
110
+ value.strip
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Overseer
7
+ module CLI
8
+ module Commands
9
+ # `ace-overseer start [--dry-run] [--format table|json]`
10
+ # (task 8xs.t.pu0.1): starts or reuses the project overseer
11
+ # session from the directory's explicit start configuration.
12
+ # Exit 0 only for started/reused (dry-run reports the plan with
13
+ # exit 0 but creates nothing); a blocked start is a CLI error.
14
+ class Start < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+
17
+ desc "Start or reuse the project overseer session from .ace/overseer/config.yml"
18
+
19
+ option :dry_run, type: :boolean, default: false, desc: "Show the start plan without creating a session"
20
+ option :format, default: "table", desc: "Output format (table, json)"
21
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
22
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
23
+
24
+ def initialize(orchestrator: nil, config: nil)
25
+ super()
26
+ @orchestrator = orchestrator
27
+ @config = config
28
+ end
29
+
30
+ def call(dry_run: false, format: "table", **options)
31
+ report = orchestrator.call(dry_run: dry_run)
32
+
33
+ if options[:quiet]
34
+ # quiet still fails loudly on a blocked start
35
+ elsif format == "json"
36
+ puts JSON.pretty_generate(report)
37
+ else
38
+ render_table(report)
39
+ end
40
+
41
+ case report["status"]
42
+ when "started", "reused", "ready", "dry_run" then nil
43
+ else
44
+ raise Ace::Support::Cli::Error, report["message"] || "start blocked"
45
+ end
46
+ rescue Ace::Support::Cli::Error
47
+ raise
48
+ rescue => e
49
+ raise Ace::Support::Cli::Error.new(e.message)
50
+ end
51
+
52
+ private
53
+
54
+ def orchestrator
55
+ @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config)
56
+ end
57
+
58
+ def render_table(report)
59
+ puts "ace-overseer start: #{report['status']}"
60
+ puts " scope: #{report['scope'] || '-'}"
61
+ puts " root: #{report['root'] || '-'}"
62
+ puts " label: #{report['label'] || '-'}"
63
+ puts " profile: #{report['profile'] || '-'}"
64
+ if report["instructions"]
65
+ puts " instructions: #{report['instructions']['source']} -> #{report['instructions']['path']}"
66
+ end
67
+ puts " workspace: #{report['workspace'] || '-'}"
68
+ puts " pane: #{report['pane'] || '-'}"
69
+ puts " agent: #{report['agent']}"
70
+ puts " replaced pane: #{report['replaced_pane'] || '-'}" if report["replaced_pane"]
71
+ puts " #{report['message']}" if report["message"]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -10,6 +10,7 @@ require_relative "cli/commands/observe"
10
10
  require_relative "cli/commands/prune"
11
11
  require_relative "cli/commands/projects"
12
12
  require_relative "cli/commands/agents"
13
+ require_relative "cli/commands/start"
13
14
 
14
15
  module Ace
15
16
  module Overseer
@@ -37,6 +38,7 @@ module Ace
37
38
  "ace-overseer prune # Clean up finished tasks"
38
39
  ].freeze
39
40
 
41
+ register "start", Commands::Start
40
42
  register "work-on", Commands::WorkOn
41
43
  register "handoff", Commands::Handoff
42
44
  register "process-reports", Commands::ProcessReports
@@ -1,20 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
  require "ace/hitl/molecules/event_observation"
3
+ require "digest"
3
4
 
4
5
  module Ace
5
6
  module Overseer
6
7
  module Molecules
7
- # Read-only adoption filter. HITL owns semantic revisions; the existing
8
- # private task tree owns report adoption and continuation, never this view.
8
+ # Read-only standing observation (task 8xs.t.pu0.3). HITL owns
9
+ # semantic revisions; the existing private task tree owns report
10
+ # adoption and continuation, never this view. The projection arms
11
+ # the started overseer with the six local source classes — HITL
12
+ # events plus LocalSources (project tasks, _overseer tasks,
13
+ # worktrees, coworker availability, assignment progress) — every
14
+ # row read from its state owner, with per-source health so a
15
+ # failing source degrades only itself and healthy silence projects
16
+ # identical revisions (no model turns).
9
17
  class EventObservation
10
- def initialize(hitl: nil, task_manager: nil)
18
+ def initialize(hitl: nil, task_manager: nil, local_sources: nil, remote_sources: nil,
19
+ config: nil)
11
20
  @hitl = hitl || Ace::Hitl::Molecules::EventObservation.new
12
21
  @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
22
+ @local_sources = local_sources || LocalSources.new(task_manager: @tasks)
23
+ @remote_sources = remote_sources || RemoteSources.new
24
+ @config = config || Ace::Overseer.config
13
25
  end
14
26
 
15
27
  def call(owner:, scope: "current", limit: 500)
16
- projection = @hitl.call(owner: owner, scope: scope, limit: limit)
28
+ projection = nil
29
+ hitl_error = nil
30
+ begin
31
+ projection = @hitl.call(owner: owner, scope: scope, limit: limit)
32
+ rescue StandardError => e
33
+ hitl_error = e
34
+ end
35
+
36
+ local = @local_sources.call
37
+ remote = remote_projection
17
38
  managers = @tasks.list(in_folder: "all").select { |task| !task.metadata["project_task"].to_s.empty? }
39
+
40
+ if projection.nil?
41
+ # A degraded HITL source degrades only itself: the local
42
+ # sources keep projecting and the failure is visible data.
43
+ events = local.fetch("events") + remote.fetch("events") + [health_marker("hitl", hitl_error)]
44
+ sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
45
+ "hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
46
+ )
47
+ return {"schema" => "ace.hitl.observation/v1",
48
+ "events" => events.sort_by { |row| row["key"] },
49
+ "settled" => [], "sources" => sources}
50
+ end
51
+
18
52
  settled, pending = projection.fetch("events").partition do |event|
19
53
  next false unless event["type"] == "report"
20
54
  verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
@@ -25,7 +59,32 @@ module Ace
25
59
  # the same attempt. Only completed trees suppress historical reports.
26
60
  manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
27
61
  end
28
- projection.merge("events" => pending, "settled" => settled)
62
+
63
+ projection.merge("events" => (pending + local.fetch("events") + remote.fetch("events"))
64
+ .sort_by { |row| row["key"] },
65
+ "settled" => settled,
66
+ "sources" => local.fetch("sources").merge(remote.fetch("sources"))
67
+ .merge("hitl" => {"status" => "ok"}))
68
+ end
69
+
70
+ private
71
+
72
+ # Configured remote observation (8xs.t.pu0.4): the project's own
73
+ # observe.remote.pull_requests list — never a scan of accessible
74
+ # accounts. No configuration means no remote rows and no remote
75
+ # source to degrade.
76
+ def remote_projection
77
+ entries = Array(@config.dig("observe", "remote", "pull_requests"))
78
+ return {"events" => [], "sources" => {}} if entries.empty?
79
+
80
+ @remote_sources.call(pull_requests: entries)
81
+ end
82
+
83
+ def health_marker(name, error)
84
+ {"key" => "source:#{name}",
85
+ "revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
86
+ "type" => "source-health", "status" => "degraded",
87
+ "error" => "#{error.class}: #{error.message}"}
29
88
  end
30
89
  end
31
90
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Overseer
8
+ module Molecules
9
+ # Standing local-state observation (task 8xs.t.pu0.3): projects the
10
+ # six local source classes the started overseer watches — project
11
+ # tasks, private _overseer tasks, worktrees, coworker availability,
12
+ # assignment progress and HITL events — each read from its STATE
13
+ # OWNER (ace-task, ace-git-worktree, ace-coworker probe, ace-assign
14
+ # inventory, ace-hitl). This molecule owns no second state: every
15
+ # row is a {key, revision, type} projection whose revision changes
16
+ # only when the owner's data changes, so a consumer (eyes) wakes on
17
+ # real revisions and stays silent on identical state.
18
+ #
19
+ # A failing source degrades ONLY itself: it contributes a
20
+ # `source-health` marker whose revision is stable per error class
21
+ # (an identical repeated failure never re-wakes; a new failure mode
22
+ # wakes once), while the healthy sources keep projecting.
23
+ class LocalSources
24
+ def initialize(task_manager: nil, worktree_manager: nil, probe_factory: nil)
25
+ @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
26
+ @worktrees = worktree_manager || Ace::Git::Worktree::Organisms::WorktreeManager.new
27
+ @probe_factory = probe_factory || ->(path) {
28
+ Ace::Coworker::Molecules::WorktreeExecutorProbe.new(project_root: path)
29
+ }
30
+ end
31
+
32
+ # @return [Hash] {"events" => Array<{key, revision, type, ...}>,
33
+ # "sources" => {name => {"status" => "ok"|"degraded", "error"?}}}
34
+ def call
35
+ sources = {}
36
+ events = []
37
+
38
+ collect(sources, events, "project_tasks") { project_task_events }
39
+ collect(sources, events, "overseer_tasks") { overseer_task_events }
40
+ collect(sources, events, "worktrees") { worktree_events }
41
+ collect(sources, events, "coworkers") { coworker_events }
42
+
43
+ {"events" => events, "sources" => sources}
44
+ end
45
+
46
+ private
47
+
48
+ def collect(sources, events, name)
49
+ rows = yield
50
+ sources[name] = {"status" => "ok"}
51
+ events.concat(rows)
52
+ rescue StandardError => e
53
+ sources[name] = {"status" => "degraded", "error" => "#{e.class}: #{e.message}"}
54
+ events.concat([health_marker(name, e)])
55
+ end
56
+
57
+ # A degraded source surfaces as missing data — visibly, never as
58
+ # an empty healthy list. The revision is keyed by error CLASS so
59
+ # an identical repeated failure keeps the same revision (no
60
+ # wake spam) while a new failure mode revises once.
61
+ def health_marker(name, error)
62
+ {"key" => "source:#{name}",
63
+ "revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
64
+ "type" => "source-health", "status" => "degraded",
65
+ "error" => "#{error.class}: #{error.message}"}
66
+ end
67
+
68
+ def revision(payload)
69
+ Digest::SHA256.hexdigest(JSON.generate(payload))
70
+ end
71
+
72
+ def project_task_events
73
+ task_events(@tasks.list(in_folder: "next"), "task")
74
+ end
75
+
76
+ def overseer_task_events
77
+ task_events(@tasks.list(in_folder: "_overseer"), "overseer-task")
78
+ end
79
+
80
+ # Task rows revision on real lifecycle facts (status, dependency
81
+ # set, subtask statuses) — not on cosmetic frontmatter churn.
82
+ def task_events(tasks, type)
83
+ tasks.map do |task|
84
+ payload = {"id" => task.id, "status" => task.status,
85
+ "dependencies" => Array(task.dependencies).sort,
86
+ "subtasks" => Array(task.subtasks).map { |sub| [sub.id, sub.status] }.sort}
87
+ {"key" => "#{type}:#{task.id}", "revision" => revision(payload),
88
+ "type" => type, "id" => task.id, "status" => task.status,
89
+ "title" => task.title, "dependencies" => Array(task.dependencies).sort}
90
+ end
91
+ end
92
+
93
+ def worktree_events
94
+ result = @worktrees.list_all
95
+ raise Error, result[:error] || "failed to list worktrees" unless result[:success]
96
+
97
+ Array(result[:worktrees]).reject(&:bare).map do |worktree|
98
+ payload = {"path" => worktree.path, "branch" => worktree.branch}
99
+ {"key" => "worktree:#{Digest::SHA256.hexdigest(worktree.path)}",
100
+ "revision" => revision(payload), "type" => "worktree",
101
+ "path" => worktree.path, "branch" => worktree.branch}
102
+ end
103
+ end
104
+
105
+ # Coworker availability AND assignment progress in one pass: the
106
+ # executor probe reads the worktree's commissioned config and the
107
+ # ace-assign inventory (existing run identifiers — assignment
108
+ # ids, never pane names), the ActiveExecutorRule decides live vs
109
+ # stale, and each in-flight assignment projects its own progress
110
+ # row so a step report revises that assignment only.
111
+ def coworker_events
112
+ result = @worktrees.list_all
113
+ raise Error, result[:error] || "failed to list worktrees" unless result[:success]
114
+
115
+ Array(result[:worktrees]).reject(&:bare).flat_map do |worktree|
116
+ probe = @probe_factory.call(worktree.path)
117
+ report = probe.probe(worktree.path)
118
+ verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
119
+ config: report[:config], assignments: report[:assignments]
120
+ )
121
+ identity = Digest::SHA256.hexdigest(worktree.path)
122
+ executor = {"key" => "executor:#{identity}",
123
+ "revision" => revision({"path" => worktree.path,
124
+ "active" => verdict[:active],
125
+ "evidence" => verdict[:evidence]}),
126
+ "type" => "executor", "path" => worktree.path,
127
+ "active" => verdict[:active], "evidence" => verdict[:evidence]}
128
+ assignments = report[:assignments].map do |assignment|
129
+ {"key" => "assignment:#{assignment["id"]}",
130
+ "revision" => revision({"id" => assignment["id"], "state" => assignment["state"],
131
+ "progress" => assignment["progress"]}),
132
+ "type" => "assignment", "id" => assignment["id"],
133
+ "state" => assignment["state"], "progress" => assignment["progress"]}
134
+ end
135
+ [executor] + assignments
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ 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