ace-hitl 0.12.0 → 0.13.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 +19 -0
- data/docs/usage.md +27 -34
- data/lib/ace/hitl/cli/commands/proposal.rb +4 -2
- data/lib/ace/hitl/lifecycle/binding.rb +1 -1
- data/lib/ace/hitl/lifecycle/client.rb +63 -25
- data/lib/ace/hitl/lifecycle/kinds.rb +4 -1
- data/lib/ace/hitl/lifecycle/proposals.rb +48 -42
- data/lib/ace/hitl/lifecycle/service.rb +36 -13
- data/lib/ace/hitl/lifecycle/service_publication_binding.rb +97 -0
- data/lib/ace/hitl/lifecycle/store.rb +104 -36
- data/lib/ace/hitl/live_client.rb +9 -27
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +10 -3
- data/lib/ace/hitl/providers/lab/protected_assignment_binding.rb +75 -0
- data/lib/ace/hitl/providers/lab.rb +6 -2
- data/lib/ace/hitl/version.rb +1 -1
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e0e3e52b0322d7e5b585bf30728ebaae32337e06b4a8be12e96dcba1c0d498b
|
|
4
|
+
data.tar.gz: 4b79e7e2d5efd486c794be95cf515d147b17f322227d3cd6d6583382f3e6fb7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7abb0c60530c467efbb7a2b99d514e23a716bd61fce8d67c65966d629bb70969785043d63dd36e60eabd1d708d7d850b58eca6b31e5135c071e2791147c3bd7
|
|
7
|
+
data.tar.gz: 339b0dee9be68ff3ef82b4de954e6642a58b4ccdf8cad109524b66991423f39f543a59f75e8e957c1be23e263975a3448009a3558aa3e622415059d14ad036bd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Bind scoped publication to the original canonical HITL challenge and authorization; keep OTP material out of agent messages and ordinary transport.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Read delivery history from the original Inbox owner. Complete human attention on terminal submission without requiring an agent-read receipt or querying recovery caches.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Route proposals through the retained project journal and preserve exact protected attempt identity across lifecycle calls.
|
|
23
|
+
- Exercise concurrent creation under the actual lifecycle lock.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Accept exact protected launch attempt identifiers for HITL requests while preserving assignment syntax and authoritative binding checks.
|
|
28
|
+
|
|
10
29
|
## [0.12.0] - 2026-10-05
|
|
11
30
|
|
|
12
31
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -127,35 +127,22 @@ client.status(request: request_id)
|
|
|
127
127
|
```
|
|
128
128
|
|
|
129
129
|
`deliver` consumes an authorized ordinary answer, enqueues one incarnation-bound
|
|
130
|
-
Herdr event, registers its
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
client.reconcile(request: request_id, receipt_path: signed_receipt_path)
|
|
147
|
-
# Explicit retry only after verified supersession/non-consumption:
|
|
148
|
-
client.reconcile(request: request_id, receipt_path: signed_receipt_path, retry_delivery: true)
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
Herdr verifies the signature, exact event/attempt/digest/generation/native binding
|
|
152
|
-
and accepted registration under the event lock. Assign journals the verified
|
|
153
|
-
observation. Missing authority or signer, wrong key, changed target and stale
|
|
154
|
-
proof stay refused/unknown. Signed supersession leaves ordinary `deliver` and
|
|
155
|
-
`watch` calls queued; only `reconcile(..., retry_delivery: true)` submits again.
|
|
156
|
-
No elapsed-time rule establishes success or retries.
|
|
157
|
-
Keep the original trusted verification/signing context for unresolved events or
|
|
158
|
-
defer key rotation; a replacement fingerprint cannot rebind an existing event.
|
|
130
|
+
Herdr event, registers its original assignment/attempt attribution with Assign,
|
|
131
|
+
and sends to the exact selected terminal. The accepted owner's terminal, agent
|
|
132
|
+
and native session ID are checked against the live Herdr pane before sending.
|
|
133
|
+
A replaced agent in the same pane refuses rather than selecting another target.
|
|
134
|
+
Repeated calls reuse the same event; uncertain sends are not automatically
|
|
135
|
+
repeated. The configured Hermes transport owns Telegram request/reply routing.
|
|
136
|
+
|
|
137
|
+
A delivered acknowledgement means submitted to the terminal, not read by the
|
|
138
|
+
agent. LiveClient.status reads delivery history from the original Inbox owner;
|
|
139
|
+
delivered answers no longer appear as pending human attention. Current agent
|
|
140
|
+
progress/output comes from direct Herdr/tmux panel capture when needed.
|
|
141
|
+
|
|
142
|
+
Business effects still run through their scoped owner with their own result
|
|
143
|
+
and authorization. Neither a sent message nor panel output replaces verification
|
|
144
|
+
of those effects. No dedicated Codex app-server, message-read signer/key or
|
|
145
|
+
signed consumption/supersession reconciliation API is required.
|
|
159
146
|
|
|
160
147
|
The shared versioned envelope is semantically owned by HITL and packaged in
|
|
161
148
|
`ace-hitl-contract` to preserve the acyclic HITL → Assign → Herdr → contract
|
|
@@ -315,9 +302,9 @@ operation and returns its bytes only over protected IPC. Local event polling
|
|
|
315
302
|
remains available for ordinary unbound local events. Managed events cannot use
|
|
316
303
|
`update --resume` to launch unscoped session/shell delivery.
|
|
317
304
|
|
|
318
|
-
Installed acceptance
|
|
319
|
-
|
|
320
|
-
transport
|
|
305
|
+
Installed acceptance uses actual registered Telegram ingress, the selected
|
|
306
|
+
live Herdr/tmux terminal and the real scoped users in Lab. Controlled local
|
|
307
|
+
transport tests do not replace launching and observing the actual task.
|
|
321
308
|
|
|
322
309
|
## Lifecycle Event Names
|
|
323
310
|
|
|
@@ -370,11 +357,17 @@ publishing, deployment and access/privilege expansion. Fixed service scope, curr
|
|
|
370
357
|
independently executed review/tests, bound running attempt and operation-specific OTP gates
|
|
371
358
|
still apply at execution. Proposal authorization never supplies credentials or runs a callback.
|
|
372
359
|
|
|
373
|
-
|
|
360
|
+
All proposal reads, revisions and transport acknowledgements/replies carry an explicit project.
|
|
361
|
+
The authority selects that project's canonical Assign journal; a proposal ID does not select a journal.
|
|
362
|
+
The managed request's existing project supplies this selector for ordinary `read`; Hermes uses the
|
|
363
|
+
accepted envelope's project for acknowledgement, reply and reconciliation. Missing selectors are
|
|
364
|
+
rejected, and an ID from another project's journal cannot be read or changed through the selected project.
|
|
365
|
+
|
|
366
|
+
`ace-hitl proposal show ID --project PROJECT --format json` shows decision, deadline, actual Assign claim/outcome
|
|
374
367
|
and a bounded history page; continue with `--history-after HISTORY_NEXT`.
|
|
375
368
|
`ace-hitl proposal history --project ID --query TEXT` retrieves relevant prior decisions;
|
|
376
369
|
continue with `--after NEXT`. Current project grants and exact requester identity gate history and show; losing project access refuses reads and excludes history, including lifecycle proposal request reads.
|
|
377
|
-
`ace-hitl proposal revise ID --expected-revision N --operation-id revision-abcdef0123456789abcdef01 --file changed.json` supersedes the prior decision and creates a
|
|
370
|
+
`ace-hitl proposal revise ID --project PROJECT --expected-revision N --operation-id revision-abcdef0123456789abcdef01 --file changed.json` supersedes the prior decision and creates a
|
|
378
371
|
new request with a fresh full window after acknowledgement. An unresolved claimed effect
|
|
379
372
|
must be reconciled before revision; known successful or proven no-effect settlement can be
|
|
380
373
|
followed by a new revision. Persist the proposal ID, expected source revision, stable revision operation ID and exact file before invocation. The operation ID is `revision-` followed by 24 lowercase hex digits and is unique across proposals. Exact retry returns that committed revision, including after acknowledged delivery, approval or a later revision, without creating another request or resetting the window. Changed proposal/source/content/caller bindings and stale-source new operations are refused. Supersession and new prepared revision commit atomically against effect claims. Generic lifecycle `create` rejects proposal kind; use this canonical proposal interface.
|
|
@@ -36,12 +36,14 @@ module Ace
|
|
|
36
36
|
project: options[:project], document: load_document(options[:file]))
|
|
37
37
|
when "show"
|
|
38
38
|
raise_lifecycle_error("proposal ID required") if id.to_s.empty?
|
|
39
|
-
|
|
39
|
+
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
40
|
+
lifecycle_client.proposal_show(id, project: options[:project], history_after: options[:"history-after"] || 0)
|
|
40
41
|
when "revise"
|
|
41
42
|
raise_lifecycle_error("proposal ID required") if id.to_s.empty?
|
|
42
43
|
raise_lifecycle_error("--expected-revision required") unless options[:"expected-revision"]
|
|
43
44
|
raise_lifecycle_error("--operation-id required") if options[:"operation-id"].to_s.empty?
|
|
44
|
-
|
|
45
|
+
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
46
|
+
lifecycle_client.proposal_revise(id, project: options[:project], expected_revision: options[:"expected-revision"],
|
|
45
47
|
operation_id: options[:"operation-id"], document: load_document(options[:file]))
|
|
46
48
|
when "resolve-due"
|
|
47
49
|
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
@@ -27,32 +27,32 @@ module Ace
|
|
|
27
27
|
request("proposal-create", params)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def proposal_show(id, history_after: 0)
|
|
31
|
-
request("proposal-show", {"id" => id, "history_after" => history_after})
|
|
30
|
+
def proposal_show(id, project:, history_after: 0)
|
|
31
|
+
request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def proposal_revise(id, expected_revision:, operation_id:, document:)
|
|
35
|
-
request("proposal-revise", {"id" => id, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
|
|
34
|
+
def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
|
|
35
|
+
request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def proposal_acknowledge(id, submitted_at:)
|
|
39
|
-
request("proposal-ack", {"id" => id, "submitted_at" => submitted_at})
|
|
38
|
+
def proposal_acknowledge(id, project:, submitted_at:)
|
|
39
|
+
request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def proposal_reply(id, **params)
|
|
43
|
-
request("proposal-reply", {"id" => id}.merge(params))
|
|
42
|
+
def proposal_reply(id, project:, **params)
|
|
43
|
+
request("proposal-reply", {"id" => id, "project" => project}.merge(params))
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def proposal_reconcile(id, checkpoint:)
|
|
47
|
-
request("proposal-reconcile", {"id" => id, "checkpoint" => checkpoint})
|
|
46
|
+
def proposal_reconcile(id, project:, checkpoint:)
|
|
47
|
+
request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def proposal_wake(project:, after: nil)
|
|
51
51
|
request("proposal-wake", {"project" => project, "after" => after}.compact)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def proposal_due(after: nil)
|
|
55
|
-
request("proposal-due", {"after" => after}.compact)
|
|
54
|
+
def proposal_due(project:, after: nil)
|
|
55
|
+
request("proposal-due", {"project" => project, "after" => after}.compact)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def proposal_history(project:, query: "", after: nil)
|
|
@@ -66,14 +66,15 @@ module Ace
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# @return [Hash] the result frame
|
|
69
|
-
def request(op, params = {})
|
|
69
|
+
def request(op, params = {}, deadline: nil)
|
|
70
|
+
deadline = request_deadline(op, params, deadline)
|
|
70
71
|
verify_endpoint!
|
|
71
|
-
socket =
|
|
72
|
+
socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
|
|
72
73
|
begin
|
|
74
|
+
connect!(socket, deadline)
|
|
73
75
|
verify_service_peer!(socket)
|
|
74
|
-
socket
|
|
75
|
-
socket
|
|
76
|
-
line = read_line(socket, deadline_for(op, params))
|
|
76
|
+
write_frame!(socket, Protocol.encode_request(op, params), deadline)
|
|
77
|
+
line = read_line(socket, deadline)
|
|
77
78
|
Protocol.decode_response(line)
|
|
78
79
|
ensure
|
|
79
80
|
socket.close
|
|
@@ -84,27 +85,27 @@ module Ace
|
|
|
84
85
|
raise TransportError, "HITL boundary request is not serializable (#{e.message})"
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
def create(**params)
|
|
88
|
-
request("create", params.transform_keys(&:to_s))
|
|
88
|
+
def create(deadline: nil, **params)
|
|
89
|
+
request("create", params.transform_keys(&:to_s), deadline: deadline)
|
|
89
90
|
end
|
|
90
91
|
|
|
91
|
-
def read(id)
|
|
92
|
-
request("read", {"id" => id})
|
|
92
|
+
def read(id, deadline: nil)
|
|
93
|
+
request("read", {"id" => id}, deadline: deadline)
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
def deliver(id, answer)
|
|
96
97
|
request("deliver", {"id" => id, "answer" => answer.to_s})
|
|
97
98
|
end
|
|
98
99
|
|
|
99
|
-
def consume(id, timeout: 0, operation: nil, native_delivery: false)
|
|
100
|
+
def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
|
|
100
101
|
params = {"id" => id, "timeout" => Integer(timeout)}
|
|
101
102
|
params["operation"] = operation if operation
|
|
102
103
|
params["native_delivery"] = true if native_delivery
|
|
103
|
-
request("consume", params)
|
|
104
|
+
request("consume", params, deadline: deadline)
|
|
104
105
|
end
|
|
105
106
|
|
|
106
|
-
def cancel(id, reason: "")
|
|
107
|
-
request("cancel", {"id" => id, "reason" => reason.to_s})
|
|
107
|
+
def cancel(id, reason: "", deadline: nil)
|
|
108
|
+
request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
def pending(project: nil)
|
|
@@ -177,6 +178,43 @@ module Ace
|
|
|
177
178
|
# A consume without a timeout waits indefinitely at the client
|
|
178
179
|
# boundary too; every bounded call gets a deadline slightly
|
|
179
180
|
# beyond its own wait so the server result wins the race.
|
|
181
|
+
def request_deadline(op, params, selected)
|
|
182
|
+
if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
|
|
183
|
+
raise TransportError, "HITL boundary deadline is invalid"
|
|
184
|
+
end
|
|
185
|
+
local = deadline_for(op, params)
|
|
186
|
+
[selected, local].compact.min
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def await!(socket, direction, deadline)
|
|
190
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
192
|
+
readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
|
|
193
|
+
raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def connect!(socket, deadline)
|
|
197
|
+
if deadline && deadline <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
198
|
+
raise TransportError, "HITL boundary deadline exceeded"
|
|
199
|
+
end
|
|
200
|
+
result = socket.connect_nonblock(Socket.sockaddr_un(@socket_path), exception: false)
|
|
201
|
+
return if result == 0
|
|
202
|
+
await!(socket, :write, deadline)
|
|
203
|
+
error = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
|
|
204
|
+
raise SystemCallError.new("HITL boundary connection failed", error) unless error.zero?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def write_frame!(socket, frame, deadline)
|
|
208
|
+
offset = 0
|
|
209
|
+
while offset < frame.bytesize
|
|
210
|
+
await!(socket, :write, deadline)
|
|
211
|
+
written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
|
|
212
|
+
next if written == :wait_writable
|
|
213
|
+
raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
|
|
214
|
+
offset += written
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
180
218
|
def deadline_for(op, params)
|
|
181
219
|
if op == "consume" && Integer(params["timeout"]) <= 0
|
|
182
220
|
nil
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ace/hitl/contract/secret_gate"
|
|
4
|
+
require "ace/hitl/contract/managed_envelope"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Hitl
|
|
@@ -16,8 +17,10 @@ module Ace
|
|
|
16
17
|
|
|
17
18
|
REQUEST_ID = /\A[A-Za-z0-9_-]{6,64}\z/
|
|
18
19
|
# Compact managed identifiers (ADR-030): ace-assign assignment and
|
|
19
|
-
# attempt ids are Base36 timestamp ids plus optional local suffix.
|
|
20
|
+
# standalone attempt ids are Base36 timestamp ids plus optional local suffix.
|
|
21
|
+
# Protected attempts retain the exact LaunchLifecycle launch identity.
|
|
20
22
|
COMPACT_ID = /\A[0-9a-z][0-9a-z]{4,63}\z/
|
|
23
|
+
PROTECTED_ATTEMPT_ID = Contract::ManagedEnvelope::PROTECTED_ATTEMPT
|
|
21
24
|
SAFE_LABEL = /\A[A-Za-z0-9_. -]{1,48}\z/
|
|
22
25
|
OTP_ANSWER = /\A[0-9]{6}\z/
|
|
23
26
|
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
|
|
13
13
|
raise StateError, "create requires a stable proposal ID"
|
|
14
14
|
end
|
|
15
|
-
existing = proposal_journal.proposal_record(id)
|
|
15
|
+
existing = proposal_journal(project).proposal_record(id)
|
|
16
16
|
if existing && existing.values_at("assignment_id", "attempt_id", "project_id", "requester", "caller_uid", "content_digest") !=
|
|
17
17
|
[assignment, attempt, project, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
18
18
|
raise StateError, "proposal retry changed immutable content or caller"
|
|
@@ -20,15 +20,15 @@ module Ace
|
|
|
20
20
|
proposal_revision(id, 1, assignment: assignment, attempt: attempt, project: project, content: content)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def proposal_revise(id, expected_revision:, operation_id:, document:)
|
|
24
|
-
old = proposal_access!(id, requester: true)
|
|
23
|
+
def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
|
|
24
|
+
old = proposal_access!(id, project: project, requester: true)
|
|
25
25
|
require_proposer!(old["project_id"])
|
|
26
26
|
unless expected_revision.is_a?(Integer) && expected_revision.positive? &&
|
|
27
27
|
operation_id.is_a?(String) && operation_id.match?(/\Arevision-[0-9a-f]{24}\z/)
|
|
28
28
|
raise StateError, "revise requires an expected source revision and stable revision operation ID"
|
|
29
29
|
end
|
|
30
30
|
content = Ace::Hitl::Proposals::Policy.document(document)
|
|
31
|
-
replay = proposal_revision_replay(id, expected_revision, operation_id, content)
|
|
31
|
+
replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
|
|
32
32
|
if replay
|
|
33
33
|
project_proposal_request(replay)
|
|
34
34
|
return replay
|
|
@@ -40,18 +40,18 @@ module Ace
|
|
|
40
40
|
raise StateError, "Proposal revision refused (#{e.message})"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def proposal_show(id, history_after: 0)
|
|
44
|
-
record = proposal_access!(id)
|
|
43
|
+
def proposal_show(id, project:, history_after: 0)
|
|
44
|
+
record = proposal_access!(id, project: project)
|
|
45
45
|
unless history_after.is_a?(Integer) && history_after >= 0
|
|
46
46
|
raise StateError, "history cursor must be a non-negative offset"
|
|
47
47
|
end
|
|
48
|
-
history = proposal_journal.proposal_history(id)
|
|
48
|
+
history = proposal_journal(project).proposal_history(id)
|
|
49
49
|
entries = (history.slice(history_after, 3) || []).map do |entry|
|
|
50
|
-
outcome = proposal_journal.proposal_claims(entry["revision_id"]).last
|
|
50
|
+
outcome = proposal_journal(project).proposal_claims(entry["revision_id"]).last
|
|
51
51
|
entry.merge("actual_outcome" => outcome && outcome["state"],
|
|
52
52
|
"effect_request_id" => outcome && outcome["request_id"])
|
|
53
53
|
end
|
|
54
|
-
claims = proposal_journal.proposal_claims(record["revision_id"])
|
|
54
|
+
claims = proposal_journal(project).proposal_claims(record["revision_id"])
|
|
55
55
|
claim = claims.last
|
|
56
56
|
outcome = case claim && claim["state"]
|
|
57
57
|
when "accepted" then "executing"
|
|
@@ -64,19 +64,19 @@ module Ace
|
|
|
64
64
|
"history_next" => history_after + entries.length < history.length ? history_after + entries.length : nil)
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def proposal_acknowledge(request, submitted_at:)
|
|
68
|
-
record = proposal_for_request!(request)
|
|
67
|
+
def proposal_acknowledge(request, project:, submitted_at:)
|
|
68
|
+
record = proposal_for_request!(request, project: project)
|
|
69
69
|
require_transport!("proposal delivery", {"project" => record["project_id"]})
|
|
70
70
|
proposal_transition(record) { |current| Ace::Hitl::Proposals::Policy.acknowledge(current, submitted_at) }
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def proposal_reply(request, answer:, received_at:, sequence:)
|
|
74
|
-
record = proposal_for_request!(request)
|
|
73
|
+
def proposal_reply(request, project:, answer:, received_at:, sequence:)
|
|
74
|
+
record = proposal_for_request!(request, project: project)
|
|
75
75
|
require_transport!("proposal reply", {"project" => record["project_id"]})
|
|
76
76
|
proposal_transition(record) do |current|
|
|
77
77
|
# Claims and this transition share one canonical ref lock/CAS.
|
|
78
|
-
claimed = proposal_journal.proposal_claims(current["revision_id"]).any?
|
|
79
|
-
applied = proposal_journal.proposal_history(current["proposal_id"]).find do |entry|
|
|
78
|
+
claimed = proposal_journal(project).proposal_claims(current["revision_id"]).any?
|
|
79
|
+
applied = proposal_journal(project).proposal_history(current["proposal_id"]).find do |entry|
|
|
80
80
|
entry["revision_id"] == current["revision_id"] && entry["reply_sequence"] == sequence
|
|
81
81
|
end
|
|
82
82
|
Ace::Hitl::Proposals::Policy.reply(current, applied: applied, answer: answer, received_at: received_at,
|
|
@@ -84,8 +84,8 @@ module Ace
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
def proposal_reconcile(request, checkpoint:)
|
|
88
|
-
record = proposal_for_request!(request)
|
|
87
|
+
def proposal_reconcile(request, project:, checkpoint:)
|
|
88
|
+
record = proposal_for_request!(request, project: project)
|
|
89
89
|
require_transport!("proposal reconciliation", {"project" => record["project_id"]})
|
|
90
90
|
proposal_transition(record) do |current|
|
|
91
91
|
Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
|
|
@@ -95,7 +95,7 @@ module Ace
|
|
|
95
95
|
def proposal_wake(project:, after: nil)
|
|
96
96
|
raise StateError, "invalid proposal wake project" unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
|
|
97
97
|
require_proposer!(project)
|
|
98
|
-
records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
98
|
+
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
99
99
|
record["project_id"] == project && (!after || record["proposal_id"] > after) &&
|
|
100
100
|
record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now
|
|
101
101
|
end
|
|
@@ -107,10 +107,10 @@ module Ace
|
|
|
107
107
|
{"items" => items, "next" => records.length > 8 ? selected.last["proposal_id"] : nil}
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def proposal_due(after: nil)
|
|
111
|
-
require_transport!("proposal deadlines")
|
|
112
|
-
records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
113
|
-
(!after || record["proposal_id"] > after) &&
|
|
110
|
+
def proposal_due(project:, after: nil)
|
|
111
|
+
require_transport!("proposal deadlines", {"project" => project})
|
|
112
|
+
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
113
|
+
record["project_id"] == project && (!after || record["proposal_id"] > after) &&
|
|
114
114
|
@policy.transport?(@identity, project: record["project_id"]) &&
|
|
115
115
|
record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now && record["reconcile_requested_at"]
|
|
116
116
|
end
|
|
@@ -123,7 +123,7 @@ module Ace
|
|
|
123
123
|
unless query.is_a?(String) && query.bytesize <= 240 && project.is_a?(String)
|
|
124
124
|
raise StateError, "history requires a project and bounded query"
|
|
125
125
|
end
|
|
126
|
-
records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
126
|
+
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
127
127
|
next false unless record["project_id"] == project && (!after || record["proposal_id"] > after)
|
|
128
128
|
visible = (record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
|
|
129
129
|
@policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)) ||
|
|
@@ -146,18 +146,21 @@ module Ace
|
|
|
146
146
|
@proposal_clock.call.utc.iso8601
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
def proposal_journal
|
|
150
|
-
|
|
149
|
+
def proposal_journal(project)
|
|
150
|
+
unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
|
|
151
|
+
raise StateError, "proposal requires an explicit project"
|
|
152
|
+
end
|
|
153
|
+
@binding.proposal_journal(project: project)
|
|
151
154
|
rescue NoMethodError
|
|
152
155
|
raise BindingError, "Managed proposal authority is unavailable"
|
|
153
156
|
end
|
|
154
157
|
|
|
155
|
-
def proposal_access!(id, requester: false)
|
|
158
|
+
def proposal_access!(id, project:, requester: false)
|
|
156
159
|
unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
|
|
157
160
|
raise StateError, "invalid proposal ID"
|
|
158
161
|
end
|
|
159
|
-
record = proposal_journal.proposal_record(id)
|
|
160
|
-
raise StateError, "unknown proposal" unless record
|
|
162
|
+
record = proposal_journal(project).proposal_record(id)
|
|
163
|
+
raise StateError, "unknown proposal" unless record && record["project_id"] == project
|
|
161
164
|
if record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
|
|
162
165
|
@policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: record["project_id"])
|
|
163
166
|
return record
|
|
@@ -172,19 +175,19 @@ module Ace
|
|
|
172
175
|
record
|
|
173
176
|
end
|
|
174
177
|
|
|
175
|
-
def proposal_for_request!(request)
|
|
178
|
+
def proposal_for_request!(request, project:)
|
|
176
179
|
unless request.is_a?(String) && request.match?(/\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/)
|
|
177
180
|
raise StateError, "invalid proposal revision request"
|
|
178
181
|
end
|
|
179
182
|
id = request.sub(/-r[1-9][0-9]*\z/, "")
|
|
180
|
-
proposal_access!(id)
|
|
181
|
-
record = proposal_journal.proposal_history(id).reverse.find { |entry| entry["request_id"] == request }
|
|
183
|
+
proposal_access!(id, project: project)
|
|
184
|
+
record = proposal_journal(project).proposal_history(id).reverse.find { |entry| entry["request_id"] == request }
|
|
182
185
|
raise StateError, "unknown proposal revision" unless record
|
|
183
186
|
record
|
|
184
187
|
end
|
|
185
188
|
|
|
186
189
|
def proposal_transition(record, &policy)
|
|
187
|
-
proposal_journal.change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
|
|
190
|
+
proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
|
|
188
191
|
attempt_id: record["attempt_id"]) do |current|
|
|
189
192
|
# A reply to an older revision cannot modify the new decision.
|
|
190
193
|
next current unless current["request_id"] == record["request_id"]
|
|
@@ -199,9 +202,9 @@ module Ace
|
|
|
199
202
|
question = "Proposal #{request}: #{JSON.generate(content)}"
|
|
200
203
|
replay = nil
|
|
201
204
|
record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
|
|
202
|
-
proposal_journal.change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
|
|
205
|
+
proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
|
|
203
206
|
if operation_id
|
|
204
|
-
replay = proposal_revision_replay(id, expected_revision, operation_id, content)
|
|
207
|
+
replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
|
|
205
208
|
next previous if replay
|
|
206
209
|
unless previous && previous["revision"] == expected_revision
|
|
207
210
|
raise StateError, "proposal source revision changed concurrently"
|
|
@@ -238,11 +241,11 @@ module Ace
|
|
|
238
241
|
record
|
|
239
242
|
end
|
|
240
243
|
|
|
241
|
-
def proposal_revision_replay(id, expected_revision, operation_id, content)
|
|
242
|
-
replay = proposal_journal.proposal_operation_record(operation_id)
|
|
244
|
+
def proposal_revision_replay(id, expected_revision, operation_id, content, project:)
|
|
245
|
+
replay = proposal_journal(project).proposal_operation_record(operation_id)
|
|
243
246
|
return unless replay
|
|
244
|
-
unless replay.values_at("proposal_id", "expected_source_revision", "requester", "caller_uid", "content_digest") ==
|
|
245
|
-
[id, expected_revision, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
247
|
+
unless replay.values_at("proposal_id", "project_id", "expected_source_revision", "requester", "caller_uid", "content_digest") ==
|
|
248
|
+
[id, project, expected_revision, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
246
249
|
raise StateError, "revision retry changed immutable source, content or caller"
|
|
247
250
|
end
|
|
248
251
|
replay
|
|
@@ -267,10 +270,13 @@ module Ace
|
|
|
267
270
|
|
|
268
271
|
def recover_proposal_projections(project:)
|
|
269
272
|
return unless @binding.respond_to?(:proposal_journal)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
next unless @policy.transport?(@identity, project:
|
|
273
|
-
|
|
273
|
+
selected_projects = project ? [project] : @binding.proposal_projects
|
|
274
|
+
selected_projects.each do |selected|
|
|
275
|
+
next unless @policy.transport?(@identity, project: selected)
|
|
276
|
+
proposal_journal(selected).proposals.each do |record|
|
|
277
|
+
next unless record["project_id"] == selected
|
|
278
|
+
project_proposal_request(record)
|
|
279
|
+
end
|
|
274
280
|
end
|
|
275
281
|
end
|
|
276
282
|
end
|