ace-hitl 0.17.0 → 0.23.2

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +60 -117
  4. data/docs/usage.md +247 -421
  5. data/handbook/workflow-instructions/hitl.wf.md +55 -6
  6. data/lib/ace/hitl/adapters/registry.rb +65 -0
  7. data/lib/ace/hitl/adapters/terminal.rb +75 -0
  8. data/lib/ace/hitl/adapters.rb +80 -0
  9. data/lib/ace/hitl/atoms/hitl_address.rb +89 -0
  10. data/lib/ace/hitl/atoms/hitl_event_text.rb +54 -0
  11. data/lib/ace/hitl/cli/commands/create.rb +81 -12
  12. data/lib/ace/hitl/cli/commands/lifecycle_command.rb +1 -7
  13. data/lib/ace/hitl/cli/commands/list.rb +22 -1
  14. data/lib/ace/hitl/cli/commands/send.rb +85 -0
  15. data/lib/ace/hitl/cli/commands/serve.rb +25 -44
  16. data/lib/ace/hitl/cli/commands/show.rb +23 -3
  17. data/lib/ace/hitl/cli/commands/update.rb +4 -0
  18. data/lib/ace/hitl/cli/commands/wait.rb +5 -0
  19. data/lib/ace/hitl/cli.rb +8 -18
  20. data/lib/ace/hitl/lifecycle/frame_serving.rb +0 -15
  21. data/lib/ace/hitl/lifecycle/kinds.rb +1 -1
  22. data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +0 -36
  23. data/lib/ace/hitl/lifecycle/policy.rb +0 -44
  24. data/lib/ace/hitl/lifecycle/protocol.rb +1 -2
  25. data/lib/ace/hitl/lifecycle/service.rb +2 -3
  26. data/lib/ace/hitl/lifecycle/store.rb +11 -75
  27. data/lib/ace/hitl/lifecycle/tls_access_policy.rb +0 -14
  28. data/lib/ace/hitl/lifecycle/tls_project_scope.rb +0 -15
  29. data/lib/ace/hitl/lifecycle/tls_service.rb +2 -3
  30. data/lib/ace/hitl/lifecycle.rb +2 -4
  31. data/lib/ace/hitl/live_client.rb +11 -87
  32. data/lib/ace/hitl/models/hitl_event.rb +35 -0
  33. data/lib/ace/hitl/molecules/hitl_address_resolver.rb +80 -0
  34. data/lib/ace/hitl/molecules/hitl_config_loader.rb +22 -1
  35. data/lib/ace/hitl/molecules/hitl_creator.rb +126 -27
  36. data/lib/ace/hitl/molecules/hitl_delivery_state.rb +58 -0
  37. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +3 -2
  38. data/lib/ace/hitl/molecules/hitl_loader.rb +19 -2
  39. data/lib/ace/hitl/molecules/hitl_route_loader.rb +108 -0
  40. data/lib/ace/hitl/organisms/hitl_manager.rb +73 -6
  41. data/lib/ace/hitl/organisms/hitl_sender.rb +148 -0
  42. data/lib/ace/hitl/providers/lab.rb +15 -129
  43. data/lib/ace/hitl/version.rb +1 -1
  44. data/lib/ace/hitl.rb +8 -1
  45. metadata +12 -43
  46. data/lib/ace/hitl/cli/commands/ask.rb +0 -136
  47. data/lib/ace/hitl/cli/commands/overseer_ack.rb +0 -30
  48. data/lib/ace/hitl/cli/commands/overseer_pending.rb +0 -28
  49. data/lib/ace/hitl/cli/commands/overseer_send.rb +0 -35
  50. data/lib/ace/hitl/cli/commands/proposal.rb +0 -75
  51. data/lib/ace/hitl/lifecycle/overseer.rb +0 -111
  52. data/lib/ace/hitl/lifecycle/proposals.rb +0 -297
  53. data/lib/ace/hitl/lifecycle/service_publication_binding.rb +0 -97
  54. data/lib/ace/hitl/proposals/evaluator.rb +0 -29
  55. data/lib/ace/hitl/proposals/policy.rb +0 -127
  56. data/lib/ace/hitl/providers/lab/assignment_binding.rb +0 -126
  57. data/lib/ace/hitl/providers/lab/protected_assignment_binding.rb +0 -75
  58. data/lib/ace/hitl/providers/providers.rb +0 -33
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Hitl
5
- module Proposals
6
- # Authenticated proposer wake. The existing Hermes actor performs
7
- # reconciliation under its own transport identity after polling.
8
- class Evaluator
9
- def initialize(boundary:, project:)
10
- @boundary, @project = boundary, project
11
- end
12
-
13
- def call
14
- results = []
15
- after = nil
16
- loop do
17
- page = @boundary.proposal_wake(project: @project, after: after)
18
- results.concat(page.fetch("items"))
19
- cursor = page.fetch("next")
20
- break unless cursor
21
- raise Lifecycle::TransportError, "proposal cursor did not advance" unless cursor.is_a?(String) && (!after || cursor > after)
22
- after = cursor
23
- end
24
- results
25
- end
26
- end
27
- end
28
- end
29
- end
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "digest"
5
- require "time"
6
- require "ace/hitl/contract/secret_gate"
7
-
8
- module Ace
9
- module Hitl
10
- module Proposals
11
- # HITL owns decisions. This policy never claims or executes an effect.
12
- module Policy
13
- WINDOW = 16 * 60 * 60
14
- APPROVED = %w[approved-explicitly approved-by-silence].freeze
15
- REQUIRED = %w[operation target candidate_head input_digest context options recommendation prerequisites].freeze
16
-
17
- module_function
18
-
19
- def document(value)
20
- data = JSON.parse(JSON.generate(value))
21
- unless data.is_a?(Hash) && (REQUIRED - data.keys).empty? &&
22
- (data.keys - REQUIRED - ["rationale"]).empty?
23
- raise Lifecycle::StateError, "proposal fields differ from proposal/v1"
24
- end
25
- unless data["operation"].is_a?(String) && data["operation"].match?(/\A[a-z][a-z0-9-]{0,63}\z/) &&
26
- data["candidate_head"].is_a?(String) && data["candidate_head"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
27
- data["input_digest"].is_a?(String) && data["input_digest"].match?(/\A[0-9a-f]{64}\z/)
28
- raise Lifecycle::StateError, "proposal must bind exact operation, candidate head and input digest"
29
- end
30
- target = data["target"]
31
- unless target.is_a?(Hash) && (target.keys - %w[resource artifact_digest]).empty? &&
32
- target["resource"].is_a?(String) && target["resource"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
33
- (!target["artifact_digest"] || (target["artifact_digest"].is_a?(String) &&
34
- target["artifact_digest"].match?(/\A[0-9a-f]{64}\z/)))
35
- raise Lifecycle::StateError, "proposal target must name exact resource and optional artifact digest"
36
- end
37
- target["artifact_digest"] ||= nil
38
- %w[context recommendation].each { |key| text!(data[key], key) }
39
- text!(data["rationale"], "rationale") if data.key?("rationale")
40
- %w[options prerequisites].each do |key|
41
- unless data[key].is_a?(Array) && data[key].length <= 8 && (key != "options" || !data[key].empty?)
42
- raise Lifecycle::StateError, "proposal #{key} must be a bounded list"
43
- end
44
- data[key].each { |item| text!(item, key) }
45
- end
46
- if JSON.generate(data).bytesize > 3500 || Contract::SecretGate::PATTERN.match?(JSON.generate(data))
47
- raise Lifecycle::StateError, "proposal must be bounded and non-secret"
48
- end
49
- data
50
- end
51
-
52
- def text!(value, field)
53
- unless value.is_a?(String) && value.valid_encoding? && !value.strip.empty? &&
54
- value.bytesize <= 1024 && !value.include?("\0")
55
- raise Lifecycle::StateError, "invalid proposal #{field}"
56
- end
57
- end
58
-
59
- def timestamp(value)
60
- time = Time.iso8601(value.to_s)
61
- raise Lifecycle::StateError, "proposal time must be canonical UTC" unless time.utc.iso8601 == value
62
- time
63
- rescue ArgumentError
64
- raise Lifecycle::StateError, "invalid proposal timestamp"
65
- end
66
-
67
- def acknowledge(record, delivered_at)
68
- timestamp(delivered_at)
69
- return record if record["delivered_at"] == delivered_at
70
- unless record["state"] == "awaiting-delivery" && !record["delivered_at"]
71
- raise Lifecycle::StateError, "proposal delivery acknowledgement changed"
72
- end
73
- record.merge("state" => "awaiting-decision", "delivered_at" => delivered_at,
74
- "deadline" => (timestamp(delivered_at) + WINDOW).iso8601)
75
- end
76
-
77
- def reply(record, answer:, received_at:, sequence:, claimed:, applied: nil)
78
- timestamp(received_at)
79
- raise Lifecycle::StateError, "proposal reply has no admitted ingress" unless sequence.is_a?(Integer) && sequence.positive?
80
- digest = Digest::SHA256.hexdigest(JSON.generate([sequence, received_at, answer]))
81
- applied ||= record if record["reply_sequence"] == sequence
82
- if applied
83
- raise Lifecycle::StateError, "duplicate ingress sequence changed content" unless applied["reply_digest"] == digest
84
- return record
85
- end
86
- return record if %w[superseded denied].include?(record["state"])
87
- raise Lifecycle::StateError, "proposal has no acknowledged delivery" unless record["delivered_at"]
88
- text!(answer, "answer")
89
- Lifecycle::Kinds.check_answer!("proposal", answer)
90
- verb, rationale = answer.strip.split(/\s+/, 2)
91
- decision = {"approve" => "approved-explicitly", "veto" => "denied", "clarify" => "superseded"}[verb.downcase]
92
- # Scope changes and all unrecognized replies stop automatic approval.
93
- decision ||= "superseded"
94
- if claimed
95
- decision = record["state"]
96
- elsif APPROVED.include?(record["state"]) && verb.downcase == "approve"
97
- decision = record["state"]
98
- end
99
- result = record.merge("state" => decision, "last_sequence" => [record.fetch("last_sequence", 0), sequence].max,
100
- "reply_sequence" => sequence, "reply_digest" => digest,
101
- "captain_answer" => answer, "received_at" => received_at)
102
- result.delete("captain_rationale")
103
- result["captain_rationale"] = rationale if rationale && !rationale.strip.empty?
104
- result["stop_requested"] = true if claimed && verb.downcase != "approve"
105
- result["resolution"] = claimed ? "effect-already-claimed; stop remaining safely stoppable work" : decision
106
- result
107
- end
108
-
109
- def resolve(record, checkpoint:, now:)
110
- return record unless record["state"] == "awaiting-decision"
111
- return record unless record["silence_delegated"] == true
112
- return record if timestamp(now) < timestamp(record.fetch("deadline"))
113
- valid = checkpoint["schema"] == "ace.hitl.hermes.ingress-checkpoint/v1" &&
114
- checkpoint["project"].is_a?(String) && checkpoint["project"] == record["project_id"] &&
115
- checkpoint["request"] == record["request_id"] && checkpoint["revision"] == record["revision_id"] &&
116
- checkpoint["healthy"] == true && checkpoint["drained"] == true &&
117
- checkpoint.dig("checkpoint", "through") == record["deadline"] &&
118
- checkpoint.dig("checkpoint", "sequence").is_a?(Integer) &&
119
- checkpoint.dig("checkpoint", "sequence") >= record.fetch("last_sequence", 0)
120
- return record unless valid
121
- record.merge("state" => "approved-by-silence", "resolution" => "approved-by-silence",
122
- "resolved_at" => now, "ingress_checkpoint" => checkpoint["checkpoint"])
123
- end
124
- end
125
- end
126
- end
127
- end
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/assign/organisms/attempt_coordinator"
4
- require_relative "../../lifecycle/binding"
5
-
6
- module Ace
7
- module Hitl
8
- module Providers
9
- class Lab
10
- # The managed binding policy (spec 8wq.t.34i): requests bind to
11
- # the exact ACTIVE MANAGED attempt of the requesting actor,
12
- # verified by the authoritative ace-assign coordinator under the
13
- # assignment exclusion. with_active HOLDS that exclusion across
14
- # the caller's locked transition, so an attempt that ends
15
- # concurrently cancels the request instead of handing it fresh
16
- # authority. Every doubt maps to the classified BindingError —
17
- # unknown identity/authority is an error, never permission.
18
- class AssignmentBinding < Lifecycle::Binding
19
- def initialize(coordinator: nil, repo_root: nil, project: nil, owner_guard: nil)
20
- unless (project.nil? && owner_guard.nil?) ||
21
- (project.is_a?(String) && !project.empty? && owner_guard.respond_to?(:call))
22
- raise ArgumentError, "installed binding needs both a fixed project and its owner guard"
23
- end
24
- @coordinator = coordinator
25
- @repo_root = repo_root
26
- @project, @owner_guard = project, owner_guard
27
- end
28
-
29
- def validate_request(assignment:, attempt:, project:, requester:, caller_pid: nil)
30
- with_verified(assignment, attempt, project, requester) do
31
- reverse_address(assignment: assignment, attempt: attempt, project: project, caller_pid: caller_pid)
32
- end
33
- end
34
-
35
- # Read-only owner verification can run inside an already-held
36
- # assignment exclusion without reacquiring that exclusion.
37
- def reverse_address(assignment:, attempt:, project:, caller_pid:)
38
- verify_selection!(project)
39
- unless caller_pid.is_a?(Integer) && caller_pid.positive?
40
- raise Lifecycle::BindingError, "kernel requester process identity is unavailable"
41
- end
42
- binding = coordinator.runtime_binding(attempt_id: attempt, caller_pid: caller_pid)
43
- {"schema" => Ref::SCHEMA, "session" => binding.fetch("session"), "pane" => binding.fetch("pane")}
44
- rescue Ace::Assign::Error => e
45
- raise Lifecycle::BindingError, "native requester authority is unavailable (#{e.message})"
46
- end
47
-
48
- # The liveness scope: the assignment exclusion is HELD across
49
- # the yielded transition commit.
50
- def with_active(assignment:, attempt:, project: nil, requester: nil)
51
- with_verified(assignment, attempt, project, requester) { yield }
52
- end
53
-
54
- def proposal_journal(project:)
55
- unless project.is_a?(String) && !project.empty?
56
- raise Lifecycle::BindingError, "proposal requires an explicit project"
57
- end
58
- verify_selection!(project)
59
- coordinator.send(:journal_for)
60
- end
61
-
62
- def proposal_projects
63
- if @project
64
- verify_selection!(@project)
65
- return [@project]
66
- end
67
- coordinator.send(:journal_for).proposals.map { |record| record.fetch("project_id") }.uniq.sort
68
- end
69
-
70
- private
71
-
72
- def verify_selection!(project)
73
- return unless @project
74
- raise Lifecycle::BindingError, "project differs from installed HITL owner" unless project == @project
75
- @owner_guard.call
76
- end
77
-
78
- def with_verified(assignment_id, attempt_id, project_id, requester)
79
- # ONLY the authority verification is error-mapped: the
80
- # caller's transition (answer hand-off, commits, effects)
81
- # keeps its own failure classes — mapping it into
82
- # BindingError would cancel a live request on an unrelated
83
- # failure (review 8x327bud). The exclusion still spans the
84
- # whole transition, so the phase flag separates the two.
85
- verifying = true
86
- verify_selection!(project_id)
87
- coordinator.with_verified_attempt(
88
- assignment_id: assignment_id,
89
- attempt_id: attempt_id,
90
- project_id: project_id,
91
- requester: requester
92
- ) do |attempt|
93
- verify_selection!(project_id)
94
- verifying = false
95
- yield attempt
96
- end
97
- rescue Ace::Assign::Error => e
98
- raise verifying ? map_error(e) : e
99
- end
100
-
101
- def coordinator
102
- @coordinator ||= Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
103
- end
104
-
105
- def map_error(error)
106
- message = error.message.to_s
107
- case error.class.name
108
- when /NotFound/
109
- # An unknown attempt id is a verifiably dead binding.
110
- Lifecycle::EndedAttemptError.new("HITL attempt is unknown: #{message}")
111
- when /ReceiptRejected/
112
- Lifecycle::EndedAttemptError.new(message)
113
- when /UnauthorizedIdentity/
114
- # A foreign actor on the record means the attempt moved on.
115
- Lifecycle::EndedAttemptError.new("HITL requester does not own the attempt: #{message}")
116
- when /InvalidState/
117
- Lifecycle::EndedAttemptError.new(message)
118
- else
119
- Lifecycle::BindingError.new(message)
120
- end
121
- end
122
- end
123
- end
124
- end
125
- end
126
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
- require "etc"
3
- require_relative "../../lifecycle/binding"
4
-
5
- module Ace
6
- module Hitl
7
- module Providers
8
- class Lab
9
- # The deployed shared service uses the original protected launch owner;
10
- # standalone ordinary HITL continues to select its explicit coordinator.
11
- class ProtectedAssignmentBinding < Lifecycle::Binding
12
- def initialize(authority:, kernel:, journals:)
13
- @authority, @kernel = authority, kernel
14
- @journals = journals.dup.freeze
15
- end
16
-
17
- def validate_request(assignment:, attempt:, project:, requester:, caller_pid: nil)
18
- peer = actual_peer!(caller_pid)
19
- unless Etc.getpwuid(peer.fetch("uid")).name == requester
20
- raise Lifecycle::BindingError, "HITL requester differs from kernel principal"
21
- end
22
- selected(assignment, attempt, project, peer.fetch("uid"), peer) { |reverse| reverse }
23
- end
24
-
25
- def reverse_address(assignment:, attempt:, project:, caller_pid:)
26
- peer = actual_peer!(caller_pid)
27
- selected(assignment, attempt, project, peer.fetch("uid"), peer) { |reverse| reverse }
28
- end
29
-
30
- def with_active(assignment:, attempt:, project:, requester:)
31
- uid = Etc.getpwnam(requester).uid
32
- selected(assignment, attempt, project, uid, nil) { yield }
33
- rescue ArgumentError
34
- raise Lifecycle::BindingError, "HITL requester is unavailable"
35
- end
36
-
37
- def proposal_journal(project:)
38
- @journals.fetch(project)
39
- rescue KeyError
40
- raise Lifecycle::BindingError, "HITL project journal is not installed"
41
- end
42
-
43
- def proposal_projects
44
- @journals.keys.sort.freeze
45
- end
46
-
47
- private
48
-
49
- def actual_peer!(pid)
50
- unless pid.is_a?(Integer) && pid.positive?
51
- raise Lifecycle::BindingError, "HITL kernel requester is unavailable"
52
- end
53
- peer = @kernel.capture(pid)
54
- @kernel.live!(peer)
55
- peer
56
- end
57
-
58
- def selected(assignment, attempt, project, uid, peer, &block)
59
- proposal_journal(project: project)
60
- verifying = true
61
- @authority.with_worker_hitl!(assignment: assignment, attempt: attempt,
62
- project: project, requester_uid: uid, peer: peer) do |reverse|
63
- verifying = false
64
- block.call(reverse)
65
- end
66
- rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound => error
67
- raise verifying ? Lifecycle::EndedAttemptError.new(error.message) : error
68
- rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError => error
69
- raise verifying ? Lifecycle::BindingError.new("protected HITL owner unavailable (#{error.class.name})") : error
70
- end
71
- end
72
- end
73
- end
74
- end
75
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/hitl/contract"
4
- require_relative "lab"
5
-
6
- module Ace
7
- module Hitl
8
- # HITL provider adapters (spec 8wm.t.vrz): pluggable ask/delivery
9
- # transports behind one interface. Agent-facing ace-hitl paths resolve
10
- # adapters through this registry only — never a concrete transport.
11
- module Providers
12
- REGISTRY = {
13
- Lab::PROVIDER_NAME => -> { Lab.new }
14
- }.freeze
15
-
16
- module_function
17
-
18
- def available
19
- REGISTRY.keys.sort
20
- end
21
-
22
- def resolve(name)
23
- factory = REGISTRY[name.to_s]
24
- unless factory
25
- raise UnknownProviderError,
26
- "unknown HITL provider '#{name}' (available: #{available.join(', ')})"
27
- end
28
-
29
- factory.call
30
- end
31
- end
32
- end
33
- end