ace-lab 0.6.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ad491380d732534c8ae2cbabe68bb796cf997adb19ff6dbdb4b6b66353470c6
4
- data.tar.gz: c1ba284a9cf628746450b50e1ecf6dcc03589a771fc6f1d44c2eded6afa24861
3
+ metadata.gz: bb4f48b950b3a32e27a457e36af99148a65e8a95cdbbc55a1586e4f9f2d2abd9
4
+ data.tar.gz: 361f1a4f8b0167365fe8fee52c7e78047c0f31a9d8f1b1dd9644de79e13b540f
5
5
  SHA512:
6
- metadata.gz: d6c8605dabf1a3f4bdac9f39a8afeac4c606c30832380f4b93921fc91d9bcd3ac364d1056fbb426130d25bf23410abd29cbd1dd80ec31ad99a563fe224f2c3bc
7
- data.tar.gz: d8ca70d0072169a318b7bf2cc57d291f6d9db14250c5af774920e0fb212735570ede41fdc360c1e099f8a41d4de43355bd1e7e6a7aca8a86fef8a21289ba04de
6
+ metadata.gz: c556ae2dfb4f3137d2e3a45473287c385cc265b2da115aa9db6a2fe42b1e1e61b5a4e6839d98b4b76ce093ace09032c1e8795373f41b33987c0e0127ba1e084a
7
+ data.tar.gz: 7d470523a46606f392ea1ed22cffd708861c5e635c34ff9d1321ff64d542ef45f30e5cc763ac422ec28a9add7c7ef6a26e8fb705d01f25cdfba291746c19c520
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Compose ordinary Assign, HITL and Forgejo merge under one installed owner and journal, with readiness barriers and fresh native/project admission.
15
+ - Optionally serve existing mTLS HITL beside the ordinary Unix listener under the same canonical owner, journal, proposal policy and one-use OTP vault. Validate the configured project and service identity before either listener starts.
16
+ - Add retained assignment prepare/request clients and bounded canonical worktree observations. Separate stable configuration identity from changing decisions, preserve no-effect settlement after slow provider calls, and clean delivery handler process groups.
17
+ - This source cut enables initial installation; it does not claim autonomous release or installed two-project acceptance.
18
+
19
+ ## [0.7.0] - 2026-10-16
20
+
21
+ ### Added
22
+
23
+ - Run the ordinary merge listener and receiver through `service listen`, with authenticated admission, canonical Assign acknowledgement/status/receipts, restart reconciliation and bounded fixed execution.
24
+ - Invoke and inspect real Forgejo merges under the installed scoped service identity; revalidate authorization per request and bind the provider mutation to the approved candidate head.
25
+
26
+ ### Changed
27
+
28
+ - Require ace-assign 0.69 and ace-git-forgejo 0.7 for the receiver record-listing and scoped provider APIs.
29
+
30
+ - Move the Forgejo merge provider ownership out of ace-lab (xz9.5): `ForgejoMergeAdapter` is now a thin delegation to the `ace-git-forgejo` provider (`Ace::Git::Forgejo::PullRequestApi` over its `HttpClient`, under the host-installed scoped service identity) — HTTP transport, authentication, payload shape, and failure classification live in the provider package, while the adapter keeps the receiver's closed outcome translation (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`), the exact-head binding guard, and the normalized readback/nil-absence contract unchanged.
31
+
10
32
  ## [0.6.0] - 2026-10-14
11
33
 
12
34
  ### Added
data/docs/usage.md CHANGED
@@ -121,6 +121,161 @@ Only the original OS caller identity with project visibility can read the
121
121
  request. The authoritative state survives loss of the local assignment cache
122
122
  because it lives in the separate assignment evidence Git ref.
123
123
 
124
+ ### `ace-lab service listen --mode ordinary-merge`
125
+
126
+ The installed ordinary service owns the Assign, HITL and merge endpoints in one
127
+ process. Start it as the configured service account:
128
+
129
+ ```sh
130
+ ace-lab service listen --mode ordinary-merge --project atlas \
131
+ --config /etc/lab/ace-lab/atlas-receiver.yml
132
+ ```
133
+
134
+ The receiver configuration selects its existing forge, repository, principal and
135
+ merge socket. `/etc/lab/ace-lab/assign-owners.yml` separately selects the canonical
136
+ Assign journal and native execution owner. Both selections must agree with the
137
+ ordinary project mapping in the trusted authorization document. `repo_path` is
138
+ the merge candidate repository; it does not choose the execution journal.
139
+
140
+ Each project entry in the root-installed assignment document contains:
141
+
142
+ | Entry | Installed selection |
143
+ |---|---|
144
+ | `service` | Non-root owner UID/GID, assignment socket and private state root |
145
+ | `journal` | Service-owned repository, execution ref and private evidence checkout |
146
+ | `assignment_root`, `lifecycle_root` | Separate private prepared-input and exclusion directories |
147
+ | `runtime_binary`, `native` | Installed executable and exact native server/socket generation from the installation receipt |
148
+ | `worktrees` | Allowed worktree root and Git common directory |
149
+ | `participants` | OS UID/GID/actor, native workspace and supervisor panes |
150
+ | `hitl` | Private store root, distinct socket, dedicated group, trusted grants path and optional installed TLS authority reference |
151
+
152
+ For example, the `hitl` entry inside an already selected project can be:
153
+
154
+ ```yaml
155
+ hitl:
156
+ root: /var/lib/lab/assign/atlas/hitl
157
+ socket_path: /run/lab/assign/atlas/hitl.sock
158
+ group: atlas-lifecycle
159
+ grants_path: /etc/lab/ace-lab/authorization.yml
160
+ ```
161
+
162
+ The store must be a separate child of that project's `service.state_root`; the
163
+ installer creates `service.state_root` with mode `0700`, the HITL root with its
164
+ maintained mode `0711`, and socket access before startup. The private parent keeps
165
+ the HITL tree inaccessible to other accounts; Store maintains private request and
166
+ secret subdirectories. Startup and restart validate the same layout.
167
+ `hitl.service_uid` in the grants document must equal the Assign owner UID. Native
168
+ identities come from the installed receipt and live observation, not the project
169
+ configuration cascade. HITL requests, decisions and merge claims use the same
170
+ canonical Assign journal and lifecycle exclusion.
171
+
172
+ For the existing remote Hermes transport, install its project-scoped mTLS
173
+ authority and add its absolute configuration path to this same `hitl` entry:
174
+
175
+ ```yaml
176
+ tls_authority: /etc/lab/ace-hitl/atlas-tls.json
177
+ ```
178
+
179
+ The referenced file uses the maintained `ace.hitl.tls.authority/v1` schema and
180
+ credential provisioning layout from `ace-hitl`. Its project must match this
181
+ owner; `service_user` and `service_group` must resolve to the owner's actual UID
182
+ and primary GID. The dedicated Unix control group remains `hitl.group`. Root
183
+ installs the credential references and Incus TCP proxy; callers cannot choose
184
+ them. Invalid ownership, identity, project or TLS material prevents startup.
185
+
186
+ The TLS listener runs in this same service process and shares its exact HITL
187
+ binding, store, decision policy and in-memory one-time OTP vault. Hermes uses its
188
+ verified project certificate as a remote transport identity; it does not need a
189
+ synthetic Unix transport UID. Keep `transport_uids` empty when no local transport
190
+ is installed. Configure the actual local proposer separately with
191
+ `proposal_uids` and its project grant. A proposer grant never authorizes replies
192
+ or delegates silence-based decisions. Restart the composed service after changing
193
+ its TLS authority or credential selection, retaining the canonical journal.
194
+
195
+ All selected endpoints must become ready before assignment ingress opens. A
196
+ failed endpoint shuts down the complete owner. A stale assignment endpoint needs
197
+ installer inspection; startup does not remove it or recover merge effects first.
198
+
199
+ ## Ordinary assignment client
200
+
201
+ `ace-lab assignment` selects the root-installed project owner and uses the
202
+ existing Assign protocol. Assign remains the only accepted execution journal.
203
+ Preparation captures reviewed task context and the existing managed assignment
204
+ graph; it does not start a worker or register accepted work.
205
+
206
+ ```sh
207
+ ace-lab assignment prepare --project atlas --task 8wr.t.abc --pane w1:pS \
208
+ --output .ace-local/prepared-task
209
+ ace-lab assignment request --project atlas --input .ace-local/prepared-task/request.json
210
+ ```
211
+
212
+ The output directory must not exist. Preparation retains the definition, bundle,
213
+ and registration request with one stable mutation ID. `--dependency-reports FILE`
214
+ selects an explicit JSON array of `{task_id, assignment_id, number}` entries for
215
+ dependency reports. A failed preparation keeps partial files for inspection;
216
+ it never rebuilds an existing directory. In this source checkout use
217
+ `bundle exec ace-lab` because no `bin/ace-lab` binstub is committed.
218
+
219
+ Every request file has exactly these fields:
220
+
221
+ ```json
222
+ {
223
+ "version": 1,
224
+ "project_id": "atlas",
225
+ "operation": "start_attempt",
226
+ "mutation_id": "original-start-001",
227
+ "params": {
228
+ "assignment_id": "assignment-id",
229
+ "attempt_id": "attempt-id",
230
+ "expected_generation": 0,
231
+ "scope": "010",
232
+ "head": "0123456789012345678901234567890123456789",
233
+ "pane": "w1:p2"
234
+ },
235
+ "parts": []
236
+ }
237
+ ```
238
+
239
+ Use actual retained IDs, scope, head and native pane. The actual worker executes
240
+ its start request: the owner verifies caller ancestry, live process and checkout.
241
+ A supervisor cannot impersonate another pane by changing `pane`. A new terminal
242
+ or prompt acknowledgement alone is not running proof.
243
+
244
+ | Operation | `params` beyond `assignment_id` | Transfer / authority |
245
+ |---|---|---|
246
+ | `register_assignment` | Generated by prepare | Exact prepared bundle; supervisor |
247
+ | `registration_status` | None | Read; `mutation_id: null` |
248
+ | `start_attempt` | `attempt_id`, `expected_generation: 0`, `scope`, `head`, `pane` | Actual worker start |
249
+ | `assignment_snapshot` | `attempt_id` (or null), `kind: queue` or `prepared_work` | Download; `mutation_id: null` |
250
+ | `queue_start` | `attempt_id`, `expected_generation`, `pane`, `step` (or null for next) | Original worker |
251
+ | `queue_finish` | Same as queue_start | One UTF-8 report part |
252
+ | `queue_fail` | `attempt_id`, `expected_generation`, `pane`, `message` | Original worker; unfinished work stays failed |
253
+ | `submit_result` | `attempt_id`, `expected_generation`, `pane`, `head`, `receipt_sha256` | Receipt first, then its ordered artifacts; original worker |
254
+ | `result_status` | `attempt_id` | Read; `mutation_id: null` |
255
+ | `result_snapshot` | `attempt_id`, `result_id` | Canonical receipt/artifacts download; `mutation_id: null` |
256
+ | `finish` | `attempt_id`, `expected_generation`, `pane`, `head`, `result_id`, `receipt_digest` | Supervisor accepts exact submitted result after settlement |
257
+
258
+ Upload `parts` are ordered `{file, bytes, sha256}` entries. Relative files resolve
259
+ beside the retained request. Changed, oversized or symlinked inputs refuse before
260
+ connection. Requesters never supply a wire `transfer` descriptor. Downloads require
261
+ `--output NEW_DIRECTORY`; numbered byte files and `response.json` are private
262
+ projections of the authenticated reply, not another execution store. For queue
263
+ snapshots, the first byte file contains the current step instructions and reports
264
+ as JSON; prepared-work snapshots contain the original Git bundle.
265
+
266
+ Retain each request and its exact parts before invoking it. On response loss,
267
+ inspect `registration_status` or `result_status` and reuse the same original
268
+ mutation when replay is appropriate. Never refresh its generation, replace its
269
+ bytes, or create a second worker because a reply was lost. Queue completion and
270
+ `submit_result` keep the attempt running; only the canonical accepted `finish`
271
+ reply proves terminal completion. Unknown effect or Inbox settlement refuses it.
272
+
273
+ These commands do not silently route to a local `ace-assign` cache. Missing or
274
+ changed installed owner selection is an explicit failure. Native wake consumers
275
+ must read the same canonical status and adopt its original attempt/result IDs.
276
+ Changed owner selection refuses new admission and dispatch until a controlled
277
+ restart. Existing canonical receipts remain the source for reconciliation.
278
+
124
279
  ## Error semantics
125
280
 
126
281
  Errors are one deterministic JSON document on stdout plus a non-zero exit.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../organisms/ordinary_assignment_client"
4
+
5
+ module Ace
6
+ module Lab
7
+ module CLI
8
+ module Commands
9
+ module Assignment
10
+ class Prepare < Ace::Support::Cli::Command
11
+ desc "Retain reviewed task input for ordinary Assign registration"
12
+ option :project, required: true, desc: "Installed project ID"
13
+ option :task, required: true, desc: "Reviewed leaf task ID"
14
+ option :pane, required: true, desc: "Actual supervisor pane for registration"
15
+ option :output, required: true, desc: "New private directory for retained input"
16
+ option :dependency_reports, desc: "JSON array of explicit dependency report selections"
17
+
18
+ def call(project:, task:, pane:, output:, **options)
19
+ reports = options[:dependency_reports] ? JSON.parse(Organisms::OrdinaryAssignmentClient.read_file(
20
+ options.fetch(:dependency_reports), 65_536), allow_duplicate_key: false, create_additions: false, max_nesting: 16) : []
21
+ puts JSON.generate(Organisms::OrdinaryAssignmentClient.prepare(project: project, task: task,
22
+ pane: pane, output: output, dependency_reports: reports))
23
+ rescue JSON::ParserError, ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error => error
24
+ raise Ace::Support::Cli::Error, "ordinary preparation incomplete: #{error.message}; retain existing files"
25
+ end
26
+ end
27
+
28
+ class Request < Ace::Support::Cli::Command
29
+ desc "Send one retained operation to the installed ordinary Assign owner"
30
+ option :project, required: true, desc: "Installed project ID"
31
+ option :input, required: true, desc: "Retained request JSON with original mutation and part digests"
32
+ option :output, desc: "New private directory for a canonical snapshot download"
33
+
34
+ def call(project:, input:, **options)
35
+ service = Organisms::OrdinaryAssignmentClient.new(project: project)
36
+ puts JSON.generate(service.request(input: input, output: options[:output]))
37
+ rescue ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Lab::Error => error
38
+ raise Ace::Support::Cli::Error, "ordinary assignment unavailable: #{error.message}; retain the original request; inspect status before new work"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -6,6 +6,10 @@ require "ace/assign"
6
6
  require_relative "support"
7
7
  require_relative "../../organisms/protected_service_request"
8
8
  require_relative "../../organisms/ordinary_merge_request"
9
+ require_relative "../../organisms/ordinary_merge_receiver"
10
+ require_relative "../../organisms/ordinary_authority_composition"
11
+ require_relative "../../molecules/ordinary_listener"
12
+ require_relative "../../molecules/ordinary_receiver_config"
9
13
 
10
14
  module Ace
11
15
  module Lab
@@ -169,6 +173,66 @@ module Ace
169
173
  @ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
170
174
  end
171
175
  end
176
+
177
+ # The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
178
+ # execution clauses; 8x3.t.xz9.5 integration handoff): binds the
179
+ # configured unix socket, mutually authenticates every connection
180
+ # through OS peer credentials, admits through the canonical Assign
181
+ # coordinator, executes the authorized fixed operation — one REAL
182
+ # Forgejo merge under the scoped host-installed service identity —
183
+ # and settles the exact terminal receipt proven by real forge
184
+ # readback. Configuration is one explicit deployment-owned file
185
+ # plus the host-installed authorization and forge-merge documents;
186
+ # the host owns their placement and credentials. Blocks until
187
+ # stopped.
188
+ class Listen < Ace::Support::Cli::Command
189
+ include Ace::Support::Cli::Base
190
+ include Output
191
+
192
+ desc "Serve ordinary Assign, HITL and fixed merge under the installed journal owner"
193
+ option :mode, type: :string, required: true,
194
+ desc: "ordinary-merge selects the ordinary fixed merge receiver"
195
+ option :project, type: :string, required: true, desc: "Project stable ID"
196
+ option :config, type: :string, required: true,
197
+ desc: "Deployment-owned receiver configuration file (YAML)"
198
+
199
+ def call(mode:, project:, config:, **options)
200
+ unless options.empty?
201
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
202
+ "ordinary_merge_invalid_request",
203
+ "unknown listen selector: #{options.keys.sort.join(", ")}",
204
+ request_id: "unknown", input_digest: nil
205
+ ))
206
+ end
207
+ unless mode == Organisms::OrdinaryMergeRequest::MODE
208
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
209
+ "ordinary_merge_invalid_request", "unknown service mode",
210
+ request_id: "unknown", input_digest: nil
211
+ ))
212
+ end
213
+ receiver_config = Molecules::OrdinaryReceiverConfig.load(config)
214
+ unless receiver_config.project == project
215
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
216
+ "ordinary_merge_selection_mismatch",
217
+ "configured project does not match the requested project",
218
+ request_id: "unknown", input_digest: nil
219
+ ))
220
+ end
221
+ unless receiver_config.receiver_uid == Process.uid
222
+ raise Ace::Lab::InvalidConfigurationError, "receiver must run as its configured UID"
223
+ end
224
+ server = Organisms::OrdinaryAuthorityComposition.new(receiver_config: receiver_config).build
225
+ signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
226
+ server.serve
227
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Hitl::Lifecycle::Error => e
228
+ emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
229
+ "ordinary_merge_unavailable", e.message,
230
+ request_id: "unknown", input_digest: nil
231
+ ))
232
+ ensure
233
+ signals&.each { |signal, handler| Signal.trap(signal, handler) }
234
+ end
235
+ end
172
236
  end
173
237
  end
174
238
  end
data/lib/ace/lab/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
11
  require_relative "cli/commands/service"
12
+ require_relative "cli/commands/assignment"
12
13
 
13
14
  module Ace
14
15
  module Lab
@@ -26,7 +27,10 @@ module Ace
26
27
  ["resolve", "Resolve one lab entry by exact stable ID"],
27
28
  ["route", "Select a configured capable service in a project"],
28
29
  ["service request", "Request one authorized service operation"],
29
- ["service status", "Read a service request outcome"]
30
+ ["service status", "Read a service request outcome"],
31
+ ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
32
+ ["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
33
+ ["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
30
34
  ].freeze
31
35
 
32
36
  HELP_EXAMPLES = [
@@ -52,6 +56,9 @@ module Ace
52
56
  register "route", CLI::Commands::Route.new
53
57
  register "service request", CLI::Commands::Service::Request.new
54
58
  register "service status", CLI::Commands::Service::Status.new
59
+ register "service listen", CLI::Commands::Service::Listen.new
60
+ register "assignment prepare", CLI::Commands::Assignment::Prepare.new
61
+ register "assignment request", CLI::Commands::Assignment::Request.new
55
62
 
56
63
  # Register version command
57
64
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+ require_relative "grant_resolver"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # The host-installed forge merge configuration for the ordinary merge
11
+ # receiver (spec 8x3.t.xz9.5 integration handoff). The scoped service
12
+ # identity that may merge pull requests is provisioned by the HOST in
13
+ # one root-owned file at a fixed path; this loader reads it through the
14
+ # same trusted-storage verification as the authorization grants and
15
+ # fails closed on anything else. An absent file is an immediate
16
+ # actionable refusal naming the path and its owner — never a silent
17
+ # local fallback and never an invented credential: a receiver without
18
+ # the host-installed identity cannot merge and must say so.
19
+ #
20
+ # Contract (host installs, root-owned 0640, service-readable):
21
+ #
22
+ # base_url: https://forgejo.example.net
23
+ # owner: cs3b
24
+ # repo: ace
25
+ # token: <scoped: PR read + PR merge + PR comment on that repo only>
26
+ # merge_timeout_seconds: 900
27
+ # escalation_owner: host-integrator
28
+ # review_attachment:
29
+ # enabled: true
30
+ class ForgeMergeConfig
31
+ DEFAULT_PATH = "/etc/lab/ace-lab/forge-merge.yml"
32
+ DEFAULT_ESCALATION_OWNER = "host-integrator"
33
+ KEYS = %w[base_url owner repo token merge_timeout_seconds escalation_owner
34
+ review_attachment].freeze
35
+
36
+ # @raise [Ace::Lab::InvalidConfigurationError] absent file ⇒ the
37
+ # actionable `config missing:` message naming the path and owner;
38
+ # every other trust/shape failure also fails closed.
39
+ def self.load(path = DEFAULT_PATH)
40
+ content = GrantResolver.read_verified(path)
41
+ unless content
42
+ raise Ace::Lab::InvalidConfigurationError,
43
+ "config missing: #{path} (owner: #{DEFAULT_ESCALATION_OWNER} — install the " \
44
+ "root-owned 0640 forge merge configuration for the scoped service identity)"
45
+ end
46
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: false)
47
+ new(document, source: path)
48
+ rescue Psych::Exception
49
+ raise Ace::Lab::InvalidConfigurationError,
50
+ "invalid lab configuration: forge merge configuration #{path} could not be parsed"
51
+ end
52
+
53
+ attr_reader :base_url, :owner, :repo, :token, :merge_timeout_seconds, :escalation_owner,
54
+ :review_attachment_enabled, :source
55
+
56
+ def initialize(document, source: nil)
57
+ unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
58
+ (KEYS - document.keys).empty?
59
+ raise Ace::Lab::InvalidConfigurationError,
60
+ "forge merge configuration must contain exactly: #{KEYS.join(", ")}"
61
+ end
62
+ @source = source
63
+ @base_url = base_url!(document.fetch("base_url"))
64
+ @owner = name!(document.fetch("owner"), "owner")
65
+ @repo = name!(document.fetch("repo"), "repo")
66
+ @token = token!(document.fetch("token"))
67
+ @merge_timeout_seconds = timeout!(document.fetch("merge_timeout_seconds"))
68
+ @escalation_owner = name!(document.fetch("escalation_owner"), "escalation_owner")
69
+ attachment = document.fetch("review_attachment")
70
+ unless attachment.is_a?(Hash) && attachment.keys == %w[enabled] &&
71
+ [true, false].include?(attachment.fetch("enabled"))
72
+ raise Ace::Lab::InvalidConfigurationError,
73
+ "forge merge review_attachment must be exactly {enabled: true|false}"
74
+ end
75
+ @review_attachment_enabled = attachment.fetch("enabled")
76
+ freeze
77
+ end
78
+
79
+ # The token is a live credential: never rendered, never logged.
80
+ def inspect
81
+ "#<#{self.class.name} base_url=#{@base_url} owner=#{@owner} repo=#{@repo} " \
82
+ "token=[REDACTED] merge_timeout_seconds=#{@merge_timeout_seconds} " \
83
+ "escalation_owner=#{@escalation_owner} " \
84
+ "review_attachment_enabled=#{@review_attachment_enabled}>"
85
+ end
86
+
87
+ private
88
+
89
+ def base_url!(value)
90
+ uri = URI.parse(value.to_s)
91
+ unless uri.is_a?(URI::HTTPS) && uri.host && uri.path.empty? && uri.to_s == value
92
+ raise Ace::Lab::InvalidConfigurationError,
93
+ "forge merge base_url must be an https origin without path, query or fragment"
94
+ end
95
+ value
96
+ rescue URI::Error
97
+ raise Ace::Lab::InvalidConfigurationError,
98
+ "forge merge base_url must be an https origin without path, query or fragment"
99
+ end
100
+
101
+ def name!(value, name)
102
+ unless value.is_a?(String) && !value.empty? && value.length <= 256 &&
103
+ value.match?(%r{\A[A-Za-z0-9._-]+\z})
104
+ raise Ace::Lab::InvalidConfigurationError, "forge merge #{name} is invalid"
105
+ end
106
+ value
107
+ end
108
+
109
+ def token!(value)
110
+ unless value.is_a?(String) && value.length.between?(8, 512) &&
111
+ value.match?(%r{\A[A-Za-z0-9._~+/=-]+\z})
112
+ raise Ace::Lab::InvalidConfigurationError, "forge merge token is invalid"
113
+ end
114
+ value
115
+ end
116
+
117
+ def timeout!(value)
118
+ unless value.is_a?(Integer) && value.positive? && value <= 86_400
119
+ raise Ace::Lab::InvalidConfigurationError,
120
+ "forge merge merge_timeout_seconds must be a positive integer"
121
+ end
122
+ value
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git/forgejo"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # The receiver's forge merge boundary (spec 8x3.t.xz9.5 integration
9
+ # handoff): a THIN delegation to the `ace-git-forgejo` provider, which
10
+ # owns every Forgejo API/PR/review/merge/readback responsibility —
11
+ # HTTP transport, authentication, payload shape, and failure
12
+ # classification live behind `Ace::Git::Forgejo::PullRequestApi` and
13
+ # its `HttpClient`. This molecule holds no HTTP stack of its own: it
14
+ # binds the host-installed scoped service identity from
15
+ # {ForgeMergeConfig} to a repository-scoped provider client and
16
+ # translates the provider's closed outcomes into the receiver's
17
+ # closed outcome set (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`).
18
+ #
19
+ # The receiver's one authorized effect is a real pull request merge on
20
+ # the real forge, invoked under the scoped service identity, and every
21
+ # terminal outcome is proven by a real readback — a local git effect is
22
+ # never presented as a forge result.
23
+ #
24
+ # Outcome translation (provider closed world → receiver closed world):
25
+ #
26
+ # - `2xx` mutation outcome → `:invoked`; a provider refusal outcome
27
+ # (404/405/409/422 — including the server-side exact-head refusal
28
+ # for a moved head) → `:refused`; an authentication error (the
29
+ # provider raises the shared taxonomy for 401/403) → `:refused` —
30
+ # a definitive refusal can never have merged.
31
+ # - An unknown mutation outcome (5xx responses and transport failures
32
+ # on the POST — the forge may or may not have processed it) →
33
+ # `:ambiguous`; the receiver reconciles by readback, never by
34
+ # repeating the merge.
35
+ # - Every unverifiable read outcome (unreachable forge, malformed
36
+ # evidence, misrouted payload, unusable endpoint) raises
37
+ # `Unavailable`, because the true state cannot be proven. The token
38
+ # is never rendered in any error (provider-sanitized messages only).
39
+ #
40
+ # The exact-head contract is carried by the provider's native merge
41
+ # signature: the POST body binds `head_commit_id` to the approved
42
+ # candidate server-side, so a race between the state readback and the
43
+ # merge POST can never merge an unreviewed head. This molecule never
44
+ # substitutes another merge strategy and validates the binding before
45
+ # the provider is constructed.
46
+ class ForgejoMergeAdapter
47
+ # The single authorized merge strategy on the wire.
48
+ MERGE_METHOD = "fast-forward-only"
49
+
50
+ class Unavailable < ::StandardError; end
51
+
52
+ # outcome: :invoked | :refused | :ambiguous; status: Integer|nil;
53
+ # reason: closed refuse/ambiguity detail (token-free).
54
+ Result = Struct.new(:outcome, :status, :reason, keyword_init: true)
55
+
56
+ def initialize(base_url:, token:, timeout_seconds: 900)
57
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
58
+ @token = token
59
+ @timeout_seconds = timeout_seconds
60
+ freeze
61
+ end
62
+
63
+ # Invoke the eligible merge bound to the approved candidate head.
64
+ # `head_commit_id` is required and validated (full 40-character hex
65
+ # sha) BEFORE any provider construction: the forge server-side
66
+ # refuses to merge any head other than the bound one. Returns a
67
+ # Result; never raises for HTTP outcomes (ambiguity is a result, not
68
+ # an exception).
69
+ def merge_pull_request!(owner:, repo:, index:, head_commit_id:)
70
+ unless head_commit_id.is_a?(String) && head_commit_id.match?(/\A[0-9a-f]{40}\z/)
71
+ raise ArgumentError,
72
+ "head_commit_id must be a full 40-character lowercase hex sha " \
73
+ "(got: #{head_commit_id.inspect})"
74
+ end
75
+ translate_mutation do
76
+ provider(owner, repo).merge_pull_request(index,
77
+ do_method: MERGE_METHOD, head_commit_id: head_commit_id)
78
+ end
79
+ end
80
+
81
+ # The PR state readback, normalized to the receiver's closed
82
+ # PR-state shape (`merged`, `merge_commit_sha`, head/base shas) or
83
+ # nil when the forge definitively reports the PR absent (404 — the
84
+ # provider raises the absence taxonomy). Raises Unavailable for
85
+ # every unverifiable outcome.
86
+ def pull_request(owner:, repo:, index:)
87
+ payload = provider(owner, repo).pull_request(index)
88
+ {
89
+ "merged" => payload["merged"] == true,
90
+ "merge_commit_sha" => payload["merge_commit_sha"].is_a?(String) ?
91
+ payload["merge_commit_sha"] : nil,
92
+ "head" => {"sha" => payload.dig("head", "sha").is_a?(String) ?
93
+ payload.dig("head", "sha") : nil},
94
+ "base" => {"sha" => payload.dig("base", "sha").is_a?(String) ?
95
+ payload.dig("base", "sha") : nil}
96
+ }
97
+ rescue Ace::Git::ProviderObjectNotFoundError
98
+ nil
99
+ rescue Ace::Git::Error => e
100
+ raise Unavailable, e.message
101
+ end
102
+
103
+ # Attach one comment to the PR (the independent final-head review
104
+ # body handed over by the request — never the executor's own
105
+ # approval).
106
+ def create_comment(owner:, repo:, index:, body:)
107
+ translate_mutation do
108
+ provider(owner, repo).create_comment(index, body)
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # One repository-scoped provider client per exchange: the origin is
115
+ # narrowed with the forge identity this receiver already pinned
116
+ # against the host-installed configuration, and the scoped token
117
+ # rides the provider transport instead of any local credential.
118
+ def provider(owner, repo)
119
+ Ace::Git::Forgejo::PullRequestApi.new(
120
+ server: Ace::Git::ResolvedServer.new(name: "lab-forge-merge",
121
+ provider: :forgejo, url: "#{@base_url}/#{owner}/#{repo}"),
122
+ timeout: @timeout_seconds, token: @token
123
+ )
124
+ end
125
+
126
+ # Closed mutation translation: the provider returns an outcome only
127
+ # for statuses whose meaning is endpoint-specific (2xx success and
128
+ # the refusal family); everything else it raises as the shared
129
+ # taxonomy, which maps to :refused (authentication — a definitive
130
+ # refusal), :ambiguous (unknown outcome), or Unavailable (a read
131
+ # classified as unreachable).
132
+ def translate_mutation
133
+ outcome = yield
134
+ if (200..299).cover?(outcome.status)
135
+ Result.new(outcome: :invoked, status: outcome.status, reason: nil)
136
+ else
137
+ Result.new(outcome: :refused, status: outcome.status,
138
+ reason: outcome.message || "HTTP #{outcome.status}")
139
+ end
140
+ rescue Ace::Git::ProviderAuthenticationError => e
141
+ Result.new(outcome: :refused, status: nil, reason: e.message)
142
+ rescue Ace::Git::ProviderUnknownOutcomeError => e
143
+ Result.new(outcome: :ambiguous, status: nil, reason: e.message)
144
+ rescue Ace::Git::Error => e
145
+ raise Unavailable, e.message
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end