ace-overseer 0.20.0 → 0.20.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/wfi-sources/ace-overseer.yml +1 -1
  3. data/CHANGELOG.md +27 -0
  4. data/README.md +14 -14
  5. data/docs/usage.md +66 -41
  6. data/handbook/workflow-instructions/overseer.wf.md +13 -9
  7. data/handbook/workflow-instructions/roles/lab-coordinator.wf.md +99 -0
  8. data/handbook/workflow-instructions/roles/project-overseer.wf.md +103 -0
  9. data/handbook/workflow-instructions/roles/second-commander.wf.md +93 -0
  10. data/lib/ace/overseer/cli/commands/agents.rb +12 -7
  11. data/lib/ace/overseer/cli/commands/projects.rb +11 -7
  12. data/lib/ace/overseer/cli/commands/prompt.rb +44 -16
  13. data/lib/ace/overseer/cli/commands/prune.rb +51 -123
  14. data/lib/ace/overseer/cli/commands/review.rb +28 -8
  15. data/lib/ace/overseer/cli/commands/status.rb +33 -19
  16. data/lib/ace/overseer/cli/commands/stop.rb +18 -8
  17. data/lib/ace/overseer/cli/commands/work_on.rb +52 -16
  18. data/lib/ace/overseer/cli.rb +6 -9
  19. data/lib/ace/overseer/molecules/launch_request.rb +216 -0
  20. data/lib/ace/overseer/molecules/original_launch_child.rb +165 -0
  21. data/lib/ace/overseer/molecules/proposal_tick.rb +37 -9
  22. data/lib/ace/overseer/molecules/protected_selection.rb +46 -0
  23. data/lib/ace/overseer/molecules/window_opener.rb +9 -2
  24. data/lib/ace/overseer/organisms/launch_recovery.rb +65 -0
  25. data/lib/ace/overseer/organisms/protected_prune.rb +116 -0
  26. data/lib/ace/overseer/organisms/protected_review.rb +167 -0
  27. data/lib/ace/overseer/organisms/protected_status.rb +157 -0
  28. data/lib/ace/overseer/organisms/protected_steering.rb +78 -0
  29. data/lib/ace/overseer/organisms/protected_work_on.rb +102 -0
  30. data/lib/ace/overseer/organisms/work_on_orchestrator.rb +4 -5
  31. data/lib/ace/overseer/version.rb +1 -1
  32. data/lib/ace/overseer.rb +1 -2
  33. metadata +50 -13
  34. data/lib/ace/overseer/cli/commands/prepare.rb +0 -38
  35. data/lib/ace/overseer/molecules/lab_client.rb +0 -56
  36. data/lib/ace/overseer/molecules/lab_prune_safety_checker.rb +0 -69
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/lab/organisms/protected_service_client"
4
+ require_relative "../molecules/protected_selection"
5
+
6
+ module Ace
7
+ module Overseer
8
+ module Organisms
9
+ class ProtectedPrune
10
+ Preview = Ace::Lab::Atoms::ProtectedWorkspacePrunePreview
11
+
12
+ def initialize(selection: nil, document_loader: nil, client_factory: nil, authority_factory: nil)
13
+ @selection = selection || Molecules::ProtectedSelection.new
14
+ @document_loader = document_loader || -> { Ace::Lab::Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) }
15
+ @client_factory = client_factory || ->(mapping, service, deployment) {
16
+ Ace::Lab::Organisms::ProtectedServiceClient.new(mapping_id: mapping, service_id: service, deployment: deployment)
17
+ }
18
+ @authority_factory = authority_factory || ->(mapping, deployment) {
19
+ Ace::Assign::Authority::Client.new(mapping_id: mapping, deployment: deployment)
20
+ }
21
+ end
22
+
23
+ def preview(project:, agent:, assignment:, attempt:, request:)
24
+ intent = Preview.intent!(read_file(request, limit: Preview::Input::MAX_BYTES))
25
+ deployment, service = selection!(intent, project, agent, assignment, attempt)
26
+ @client_factory.call(intent.dig("maintenance", "mapping_id"), service, deployment).preview_workspace_prune(intent: intent)
27
+ rescue ArgumentError, KeyError, TypeError, JSON::ParserError, SystemCallError, IOError, SecurityError,
28
+ Ace::Runtime::RuntimeUnavailableError, Ace::Lab::Error, Ace::Assign::Error => error
29
+ raise Error, "Protected prune preview unavailable (#{error.class.name.split('::').last})"
30
+ end
31
+
32
+ def apply(project:, agent:, assignment:, attempt:, request:, mutation:, expected_generation:, authorization:)
33
+ token!(mutation); token!(authorization)
34
+ raise Error, "Expected authority generation must be a positive integer" unless expected_generation.is_a?(Integer) && expected_generation.positive?
35
+ result, intent = result_file!(request)
36
+ deployment, service = selection!(intent, project, agent, assignment, attempt)
37
+ context = result.fetch("maintenance_context")
38
+ input = Preview::Input.parse(JSON.generate({"schema" => "ace.protected-workspace-prune/v1"}.merge(
39
+ result.slice("maintenance", "target", "publication", "preservation"))))
40
+ submission = context.fetch("maintenance").slice("assignment_id", "attempt_id").merge(
41
+ "expected_generation" => expected_generation, "candidate_generation" => context.fetch("candidate_generation"),
42
+ "head" => context.fetch("head"), "request_id" => mutation, "operation" => "prune-preserved-workspace",
43
+ "authorization" => authorization, "input_digest" => Ace::Lab::Atoms::ServiceInput.digest(input),
44
+ "target" => Ace::Lab::Atoms::ServiceInput.target(input))
45
+ @client_factory.call(context.dig("maintenance", "mapping_id"), service, deployment).submit(
46
+ submission: submission, input_bytes: JSON.generate(input), mutation_id: mutation)
47
+ rescue ArgumentError, KeyError, TypeError, JSON::ParserError, SystemCallError, IOError, SecurityError,
48
+ Ace::Runtime::RuntimeUnavailableError, Ace::Lab::Error, Ace::Assign::Error => error
49
+ raise Error, "Protected prune apply unavailable (#{error.class.name.split('::').last})"
50
+ end
51
+
52
+ def status(request:, mutation:)
53
+ token!(mutation)
54
+ result, intent = result_file!(request)
55
+ target = intent.fetch("target")
56
+ deployment, = selection!(intent, *target.values_at("project_id", "mapping_id", "assignment_id", "attempt_id"))
57
+ context = result.fetch("maintenance_context")
58
+ params = context.fetch("maintenance").slice("assignment_id", "attempt_id").merge(
59
+ "head" => context.fetch("head"), "candidate_generation" => context.fetch("candidate_generation"), "request_id" => mutation)
60
+ @authority_factory.call(context.dig("maintenance", "mapping_id"), deployment).call(
61
+ "service_status", params, mutation_id: nil, timeout: 5).data
62
+ rescue ArgumentError, KeyError, TypeError, JSON::ParserError, SystemCallError, IOError, SecurityError,
63
+ Ace::Runtime::RuntimeUnavailableError, Ace::Lab::Error, Ace::Assign::Error => error
64
+ raise Error, "Protected prune status unavailable (#{error.class.name.split('::').last})"
65
+ end
66
+
67
+ private
68
+
69
+ def result_file!(path)
70
+ result = read_file(path, limit: 16_384)
71
+ intent = Preview.intent!({"schema" => "ace.protected-workspace-prune-preview/v1",
72
+ "maintenance" => result.fetch("maintenance"), "target" => result.fetch("target").except("artifact_digest"),
73
+ "publication" => result.fetch("publication"), "destinations" => result.fetch("preservation").fetch("destinations")})
74
+ context = Preview.context!(result.fetch("maintenance_context"))
75
+ raise Error, "Preview maintenance context differs" unless context.fetch("maintenance") == intent.fetch("maintenance")
76
+ [Preview.result!(result, intent: intent, context: context), intent]
77
+ end
78
+
79
+ def token!(value)
80
+ raise Error, "Explicit protected service identity is required" unless value.is_a?(String) && value.match?(Preview::Input::ID)
81
+ end
82
+
83
+ def selection!(intent, project, agent, assignment, attempt)
84
+ unless intent.fetch("target").values_at("project_id", "mapping_id", "assignment_id", "attempt_id") ==
85
+ [project, agent, assignment, attempt]
86
+ raise Error, "Protected prune target differs from CLI selection"
87
+ end
88
+ maintenance = intent.fetch("maintenance")
89
+ # The successor can omit the retired target mapping. Visibility and
90
+ # current caller admission belong to the separate maintenance actor.
91
+ deployment, = @selection.call(project: maintenance.fetch("project_id"), agent: maintenance.fetch("mapping_id"))
92
+ map = deployment.mapping(maintenance.fetch("mapping_id"))
93
+ raise Error, "Protected maintenance project differs" unless map.fetch("project_id") == maintenance.fetch("project_id")
94
+ operation = @document_loader.call.fetch("operations").fetch("prune-preserved-workspace")
95
+ service = operation.fetch("service_id")
96
+ unless operation.fetch("project") == maintenance.fetch("project_id") &&
97
+ deployment.project(maintenance.fetch("project_id")).fetch("service_receivers").key?(service)
98
+ raise Error, "Installed protected prune receiver is unavailable"
99
+ end
100
+ [deployment, service]
101
+ end
102
+
103
+ def read_file(path, limit:)
104
+ raise ArgumentError, "request FILE is required" unless path.is_a?(String) && !path.empty?
105
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
106
+ raise ArgumentError, "request FILE must be bounded and regular" unless file.stat.file? && file.stat.size.between?(1, limit)
107
+ bytes = file.read(limit + 1).force_encoding(Encoding::UTF_8)
108
+ raise ArgumentError, "request FILE must be bounded UTF-8" unless bytes.bytesize.between?(1, limit) && bytes.valid_encoding?
109
+ JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_nan: false,
110
+ allow_comments: false, allow_duplicate_key: false)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "tmpdir"
6
+ require "time"
7
+ require "ace/assign/authority/client"
8
+ require "ace/assign/authority/candidate_transfer"
9
+ require "ace/assign/models/execution_receipt"
10
+
11
+ module Ace
12
+ module Overseer
13
+ module Organisms
14
+ # The original requesting process owns the review through acceptance.
15
+ # Retained files are observation/retry evidence, never an authority cache.
16
+ class ProtectedReview
17
+ def initialize(deployment_loader: nil, kernel: nil, client_factory: nil, reviewer_factory: nil)
18
+ @deployment_loader = deployment_loader || -> { Ace::Assign::Authority::Deployment.load }
19
+ @kernel = kernel || Ace::Runtime::Molecules::ProtectedLinux.new
20
+ @client_factory = client_factory || ->(id, deployment) { Ace::Assign::Authority::Client.new(mapping_id: id, deployment: deployment, kernel: @kernel) }
21
+ @reviewer_factory = reviewer_factory || ->(root) do
22
+ require "ace/review"
23
+ Ace::Review::Organisms::ReviewManager.new(project_root: root)
24
+ end
25
+ end
26
+
27
+ def call(project:, agent:, assignment:, attempt:, mutation:, status: false, cancel: false,
28
+ head: nil, candidate_generation: nil, expected_generation: nil, accept_mutation: nil, review_event: nil)
29
+ [project, agent, assignment, attempt, mutation].each { |value| token!(value) }
30
+ raise Error, "Review modes are mutually exclusive" if status && cancel
31
+ if status
32
+ raise Error, "Status forbids mutation and candidate options" if [head, candidate_generation, expected_generation, accept_mutation, review_event].any?
33
+ else
34
+ sha!(head, 40)
35
+ positive!(candidate_generation)
36
+ positive!(expected_generation)
37
+ if cancel
38
+ sha!(review_event, 64)
39
+ raise Error, "Cancel forbids accept mutation" if accept_mutation
40
+ else
41
+ token!(accept_mutation)
42
+ raise Error, "Request and acceptance need distinct mutation IDs" if mutation == accept_mutation
43
+ raise Error, "Request forbids cancellation selector" if review_event
44
+ end
45
+ end
46
+ deployment = @deployment_loader.call
47
+ map = deployment.mapping(agent)
48
+ raise Error, "Mapping belongs to another project" unless map.fetch("project_id") == project
49
+ installed = deployment.project(project)
50
+ peer = @kernel.capture(Process.pid)
51
+ credentials = installed.fetch("peer_credentials").fetch(peer.fetch("uid").to_s)
52
+ unless installed.fetch("reviewer_uids").include?(peer.fetch("uid")) && peer.fetch("uid") != map.fetch("worker_uid") &&
53
+ peer.values_at("gid", "groups") == credentials.values_at("gid", "groups")
54
+ raise Error, "Review requires its installed independent reviewer principal"
55
+ end
56
+ client = @client_factory.call(agent, deployment)
57
+ selectors = {"assignment_id" => assignment, "attempt_id" => attempt}
58
+ return client.call("review_status", selectors.merge("mutation_id" => mutation), timeout: 30).data if status
59
+ candidate = selectors.merge("head" => head, "candidate_generation" => candidate_generation)
60
+ if cancel
61
+ return client.call("cancel_review", candidate.merge("review_event_id" => review_event,
62
+ "expected_generation" => expected_generation), mutation_id: mutation, timeout: 30).data
63
+ end
64
+ root = credentials.fetch("scratch_root")
65
+ Ace::Assign::Authority::PrivateDirectory.verify!(root)
66
+ directory = Dir.mktmpdir("review-request-", root)
67
+ File.chmod(0o700, directory)
68
+ request = candidate.merge("expected_generation" => expected_generation)
69
+ persist(directory, "request.json", JSON.generate("project" => project, "mapping" => agent,
70
+ "request" => request, "mutation" => mutation, "accept_mutation" => accept_mutation, "reviewer" => peer))
71
+ requested = true
72
+ reply = client.call("request_review", request, mutation_id: mutation, timeout: 30)
73
+ persist(directory, "assignment.json", JSON.generate(reply.data))
74
+ if reply.replayed || reply.data["state"] != "assigned"
75
+ return {"state" => reply.data.fetch("state"), "replayed" => reply.replayed,
76
+ "required_action" => "inspect_review_status", "session_dir" => directory}
77
+ end
78
+ review = reply.data.fetch("assignment")
79
+ positive!(reply.data.fetch("generation"))
80
+ unless review.is_a?(Hash) && review.values_at("head", "candidate_generation") == candidate.values_at("head", "candidate_generation") &&
81
+ review["reviewer_uid"] == peer.fetch("uid") && review["reviewer_actor"] == "uid-#{peer.fetch('uid')}" &&
82
+ @kernel.same?(review.fetch("reviewer_process_binding"), peer)
83
+ raise Error, "Assigned review differs from original requesting process or candidate"
84
+ end
85
+ token!(review.fetch("review_id"))
86
+ attempt_state = client.call("attempt_status", selectors.merge("result_candidate_generation" => candidate_generation), timeout: 30).data
87
+ unless attempt_state.values_at("assignment_id", "attempt_id") == [assignment, attempt] &&
88
+ attempt_state["scope"].is_a?(String) && attempt_state["scope"].match?(/\A[0-9]+(?:\.[0-9]+)*\z/)
89
+ raise Error, "Canonical review scope is unavailable"
90
+ end
91
+ purpose = candidate.merge("purpose_id" => review.fetch("review_id"))
92
+ exported = client.call("export_candidate", purpose, download: true, purpose: :candidate, timeout: 30)
93
+ unless exported.parts.is_a?(Array) && exported.parts.size == 1 &&
94
+ exported.data.values_at("head", "candidate_generation") == candidate.values_at("head", "candidate_generation")
95
+ raise Error, "Exported review candidate differs"
96
+ end
97
+ snapshot = Ace::Assign::Authority::CandidateTransfer.new(root: directory).review_snapshot(
98
+ bytes: exported.parts.first, size: exported.data.fetch("bytes"), sha256: exported.data.fetch("sha256"),
99
+ head: head, tree: exported.data.fetch("tree"), root: directory)
100
+ result = @reviewer_factory.call(snapshot.fetch("directory")).execute_prepared_candidate(
101
+ candidate: purpose.merge("tree" => snapshot.fetch("tree")), subject: snapshot.fetch("subject"),
102
+ session_dir: File.join(directory, "execution"))
103
+ unless result[:success] && result[:verdict] == "approved"
104
+ return {"state" => "not_approved", "verdict" => result[:verdict] || "unavailable", "session_dir" => directory}
105
+ end
106
+ artifacts = result.fetch(:artifacts)
107
+ unless artifacts.is_a?(Hash) && artifacts.size.between?(1, 16) && artifacts.all? { |name, bytes|
108
+ name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_.-]{1,128}\z/) && bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536) } &&
109
+ artifacts.values.sum(&:bytesize) <= 256 * 1024
110
+ raise Error, "Review artifacts exceed receipt bounds"
111
+ end
112
+ receipt = Ace::Assign::Models::ExecutionReceipt.new(assignment_id: assignment, attempt_id: attempt,
113
+ project_id: project, scope: attempt_state.fetch("scope"), operation: "review", head: head,
114
+ producer: {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"},
115
+ verdict: "succeeded", checks: result.fetch(:checks), recorded_at: Time.now.utc,
116
+ artifacts: artifacts.map { |name, bytes| {"path" => name, "sha256" => Digest::SHA256.hexdigest(bytes)} },
117
+ review: {"head" => head, "verdict" => "approved", "reviewer" => {"actor" => review.fetch("reviewer_actor")}})
118
+ encoded = JSON.generate(receipt.to_h)
119
+ raise Error, "Review receipt exceeds bounds" if encoded.bytesize > 65_536
120
+ persist(directory, "receipt.json", encoded)
121
+ acceptance = purpose.merge("expected_generation" => reply.data.fetch("generation"),
122
+ "receipt_sha256" => Digest::SHA256.hexdigest(encoded))
123
+ persist(directory, "acceptance.json", JSON.generate("mutation" => accept_mutation, "params" => acceptance))
124
+ @kernel.live!(peer)
125
+ unless @kernel.same?(@kernel.capture(Process.pid), review.fetch("reviewer_process_binding"))
126
+ raise Error, "Original reviewer process changed before acceptance"
127
+ end
128
+ accepted = client.call("accept_review", acceptance, mutation_id: accept_mutation,
129
+ upload_parts: [encoded, *artifacts.values], purpose: :receipt_artifacts, timeout: 30)
130
+ unless accepted.data.values_at("head", "candidate_generation", "review_id", "reviewer_uid", "uploaded_receipt_sha256") ==
131
+ [head, candidate_generation, review.fetch("review_id"), peer.fetch("uid"), Digest::SHA256.hexdigest(encoded)]
132
+ raise Error, "Canonical acceptance differs from submitted review"
133
+ end
134
+ persist(directory, "accepted.json", JSON.generate(accepted.data))
135
+ {"state" => "accepted", "acceptance" => accepted.data, "session_dir" => directory}
136
+ rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Error, ArgumentError, KeyError, TypeError, SystemCallError, IOError => error
137
+ raise Error, error.message unless requested
138
+ {"state" => "uncertain", "required_action" => "inspect_review_status", "session_dir" => directory,
139
+ "error" => error.message}
140
+ end
141
+
142
+ private
143
+
144
+ def persist(directory, name, bytes)
145
+ File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
146
+ file.write(bytes)
147
+ file.flush
148
+ file.fsync
149
+ end
150
+ File.open(directory, File::RDONLY) { |file| file.fsync }
151
+ end
152
+
153
+ def token!(value)
154
+ raise Error, "Review selector is invalid" unless value.is_a?(String) && value.match?(Ace::Assign::Authority::Deployment::TOKEN)
155
+ end
156
+
157
+ def positive!(value)
158
+ raise Error, "Review generation must be a positive integer" unless value.is_a?(Integer) && value.positive?
159
+ end
160
+
161
+ def sha!(value, length)
162
+ raise Error, "Review digest is invalid" unless value.is_a?(String) && value.match?(/\A[0-9a-f]{#{length}}\z/)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/lab"
4
+ require "ace/assign/authority/client"
5
+ require_relative "../molecules/protected_selection"
6
+
7
+ module Ace
8
+ module Overseer
9
+ module Organisms
10
+ # Public topology supplies visibility; protected Deployment supplies the
11
+ # actual fixed capacity. Canonical authority pages supply attempt facts.
12
+ # None of these metadata observations is a mutation/cleanup grant.
13
+ class ProtectedStatus
14
+ ROW_KEYS = %w[assignment_id task_id definition_digest definition_generation prepared_bundle_ref prepared_bundle_bytes prepared_bundle_sha256 selection_sha256 attempt_id scope reservation_mutation_id base_head reservation_generation generation canonical_state original_binding_digest terminal_event_id reservation_release_event_id].sort.freeze
15
+
16
+ def initialize(topology: nil, deployment_loader: nil, client_factory: nil)
17
+ @selection = Molecules::ProtectedSelection.new(topology: topology, deployment_loader: deployment_loader)
18
+ @client_factory = client_factory || ->(id, deployment) { Ace::Assign::Authority::Client.new(mapping_id: id, deployment: deployment) }
19
+ end
20
+
21
+ def collect(project:, agent: nil)
22
+ deployment, pool, ids = selection!(project, agent)
23
+ selected = agent ? [agent] : ids
24
+ snapshots = selected.map do |id|
25
+ client = @client_factory.call(id, deployment)
26
+ {"agent_id" => id, "status" => "ok", "inventory" => inventory(client, project: project, mapping_id: id)}
27
+ rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Error
28
+ {"agent_id" => id, "status" => "unavailable", "inventory" => nil}
29
+ end
30
+ {"project_id" => project, "provisioned_capacity" => pool.size, "visible_capacity" => ids.size,
31
+ "visibility" => ids == pool ? "complete" : "partial", "agents" => snapshots}
32
+ rescue KeyError, TypeError, NoMethodError
33
+ raise Error, "Protected status metadata is malformed"
34
+ end
35
+
36
+ # The parent cannot impersonate its foreground child's original birth.
37
+ # Join that child's bounded frame to its retained canonical snapshot.
38
+ def join_ready!(project:, agent:, ready:)
39
+ deployment, = selection!(project, agent)
40
+ unless ready.is_a?(Hash) && ready.keys.all? { |key| key.is_a?(String) } && ready.keys.sort == %w[assignment_id attempt_id generation journal_commit mapping_id original_binding_digest type version] &&
41
+ ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_ready" && ready["mapping_id"] == agent
42
+ raise Error, "Original launch readiness is malformed"
43
+ end
44
+ %w[assignment_id attempt_id].each { |key| token!(ready.fetch(key)) }
45
+ positive!(ready.fetch("generation"))
46
+ digest!(ready.fetch("original_binding_digest"))
47
+ unless ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
48
+ raise Error, "Original launch readiness revision is malformed"
49
+ end
50
+ snapshot = inventory(@client_factory.call(agent, deployment), project: project, mapping_id: agent, commit: ready.fetch("journal_commit"))
51
+ row = snapshot.fetch("items").find { |entry| entry.values_at("assignment_id", "attempt_id") == ready.values_at("assignment_id", "attempt_id") }
52
+ unless row && row.values_at("generation", "original_binding_digest") == ready.values_at("generation", "original_binding_digest")
53
+ raise Error, "Original launch readiness differs from canonical attempt"
54
+ end
55
+ {"journal_commit" => snapshot.fetch("journal_commit"), "item" => row}
56
+ rescue KeyError, TypeError, NoMethodError
57
+ raise Error, "Original launch readiness is malformed"
58
+ rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError
59
+ raise Error, "Original launch readiness canonical snapshot is unavailable"
60
+ end
61
+
62
+ private
63
+
64
+ def selection!(project, agent)
65
+ @selection.call(project: project, agent: agent)
66
+ end
67
+
68
+ def inventory(client, project:, mapping_id:, commit: nil)
69
+ after = nil
70
+ rows = []
71
+ selectors = {}
72
+ loop do
73
+ page = client.call("assignment_inventory", {"journal_commit" => commit, "after" => after, "limit" => 25}, timeout: 30).data
74
+ unless page.is_a?(Hash) && page.keys.all? { |key| key.is_a?(String) } && page.keys.sort == %w[items journal_commit mapping_id next_after project_id] &&
75
+ page["project_id"] == project && page["mapping_id"] == mapping_id &&
76
+ page["journal_commit"].is_a?(String) && page["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
77
+ (commit.nil? || page["journal_commit"] == commit) && page["items"].is_a?(Array) && page["items"].size <= 25
78
+ raise Error, "Protected inventory snapshot differs"
79
+ end
80
+ commit ||= page.fetch("journal_commit")
81
+ page.fetch("items").each do |row|
82
+ validate_row!(row)
83
+ selector = [row.fetch("assignment_id"), row.fetch("attempt_id")]
84
+ if selectors[selector] || (!rows.empty? && (selector_order(selector) <=> selector_order(rows.last.values_at("assignment_id", "attempt_id"))) <= 0)
85
+ raise Error, "Protected inventory ordering is ambiguous"
86
+ end
87
+ selectors[selector] = true
88
+ rows << row
89
+ end
90
+ following = page.fetch("next_after")
91
+ break if following.nil?
92
+ unless following.is_a?(Hash) && following.keys.all? { |key| key.is_a?(String) } && following.keys.sort == %w[assignment_id attempt_id] && !page.fetch("items").empty? &&
93
+ following == page.fetch("items").last.slice("assignment_id", "attempt_id") && following != after
94
+ raise Error, "Protected inventory continuation is malformed"
95
+ end
96
+ after = following
97
+ end
98
+ {"journal_commit" => commit, "items" => rows}
99
+ end
100
+
101
+ def validate_row!(row)
102
+ raise Error, "Protected inventory row is malformed" unless row.is_a?(Hash) && row.keys.all? { |key| key.is_a?(String) } && row.keys.sort == ROW_KEYS
103
+ token!(row.fetch("assignment_id"))
104
+ unless row["task_id"].is_a?(String) && !row["task_id"].empty? && row["task_id"].bytesize <= 32_768
105
+ raise Error, "Protected task identity is malformed"
106
+ end
107
+ digest!(row.fetch("definition_digest"))
108
+ positive!(row.fetch("definition_generation"))
109
+ digest!(row.fetch("prepared_bundle_sha256"))
110
+ digest!(row.fetch("selection_sha256"))
111
+ positive!(row.fetch("prepared_bundle_bytes"))
112
+ unless row.fetch("prepared_bundle_bytes") <= 64 * 1024 * 1024 &&
113
+ row.fetch("prepared_bundle_ref") == "execution/prepared/#{row.fetch('assignment_id')}-#{row.fetch('prepared_bundle_sha256')}.bundle"
114
+ raise Error, "Protected prepared bundle identity is malformed"
115
+ end
116
+ if row.fetch("attempt_id").nil?
117
+ unless row.values_at("scope", "reservation_mutation_id", "base_head", "reservation_generation", "generation", "canonical_state", "original_binding_digest", "terminal_event_id", "reservation_release_event_id").all?(&:nil?)
118
+ raise Error, "Registration-only inventory has attempt facts"
119
+ end
120
+ else
121
+ token!(row.fetch("attempt_id"))
122
+ raise Error, "Protected inventory scope is malformed" unless row["scope"].is_a?(String) && !row["scope"].empty? && row["scope"].bytesize <= 128
123
+ token!(row.fetch("reservation_mutation_id"))
124
+ unless row["base_head"].is_a?(String) && row["base_head"].match?(/\A[0-9a-f]{40}\z/)
125
+ raise Error, "Protected reservation base is malformed"
126
+ end
127
+ positive!(row.fetch("reservation_generation"))
128
+ positive!(row.fetch("generation"))
129
+ unless %w[reserved running succeeded failed stopped uncertain].include?(row.fetch("canonical_state"))
130
+ raise Error, "Protected canonical state is malformed"
131
+ end
132
+ %w[original_binding_digest terminal_event_id reservation_release_event_id].each { |key| digest!(row.fetch(key)) unless row.fetch(key).nil? }
133
+ if row["reservation_release_event_id"] && (!row["terminal_event_id"] || !%w[succeeded failed stopped].include?(row["canonical_state"]))
134
+ raise Error, "Protected release lacks terminal provenance"
135
+ end
136
+ end
137
+ end
138
+
139
+ def selector_order(value)
140
+ [value.first.b, value.last.to_s.b]
141
+ end
142
+
143
+ def token!(value)
144
+ raise Error, "Protected identity is invalid" unless value.is_a?(String) && value.match?(Ace::Assign::Authority::Deployment::TOKEN)
145
+ end
146
+
147
+ def digest!(value)
148
+ raise Error, "Protected digest is invalid" unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
149
+ end
150
+
151
+ def positive!(value)
152
+ raise Error, "Protected generation is invalid" unless value.is_a?(Integer) && value.positive?
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ require "ace/assign/authority/client"
3
+ require_relative "../molecules/protected_selection"
4
+ require_relative "protected_status"
5
+
6
+ module Ace
7
+ module Overseer
8
+ module Organisms
9
+ # Visibility and exact inventory select a target, never a replay grant.
10
+ # The authority authenticates the original principal and immutable input.
11
+ class ProtectedSteering
12
+ def initialize(selection: nil, status: nil, client_factory: nil)
13
+ @selection = selection || Molecules::ProtectedSelection.new
14
+ @status = status || ProtectedStatus.new
15
+ @client_factory = client_factory || ->(id, deployment) { Ace::Assign::Authority::Client.new(mapping_id: id, deployment: deployment) }
16
+ end
17
+
18
+ def prompt(project:, agent:, assignment:, attempt:, mutation:, expected_generation: nil, text: nil, status: false)
19
+ if status
20
+ raise Error, "Prompt status forbids text and expected generation" unless text.nil? && expected_generation.nil?
21
+ else
22
+ generation!(expected_generation)
23
+ unless text.is_a?(String) &&
24
+ text.dup.force_encoding(Encoding::UTF_8).valid_encoding? && text.bytesize.between?(1, 16_384) &&
25
+ !text.dup.force_encoding(Encoding::UTF_8).match?(/\A[[:space:]]*\z/)
26
+ raise Error, "Prompt must be bounded UTF-8 non-whitespace text"
27
+ end
28
+ end
29
+ client, selectors = target(project, agent, assignment, attempt, mutation)
30
+ if status
31
+ client.call("prompt_status", selectors.merge("mutation_id" => mutation), mutation_id: nil, timeout: 30).data
32
+ else
33
+ client.call("prompt_attempt", selectors.merge("expected_generation" => expected_generation),
34
+ mutation_id: mutation, upload_parts: [text], purpose: :prompt_text, timeout: 30).data
35
+ end
36
+ end
37
+
38
+ def stop(project:, agent:, assignment:, attempt:, mutation:, expected_generation:)
39
+ generation!(expected_generation)
40
+ client, selectors = target(project, agent, assignment, attempt, mutation)
41
+ client.call("stop_attempt", selectors.merge("expected_generation" => expected_generation), mutation_id: mutation).data
42
+ end
43
+
44
+ private
45
+
46
+ def target(project, agent, assignment, attempt, mutation)
47
+ [project, agent, assignment, attempt, mutation].each do |value|
48
+ unless value.is_a?(String) && value.match?(Ace::Assign::Authority::Deployment::TOKEN)
49
+ raise Error, "Exact protected steering identity is required"
50
+ end
51
+ end
52
+ deployment, = @selection.call(project: project, agent: agent)
53
+ snapshot = @status.collect(project: project, agent: agent)
54
+ selected = snapshot.fetch("agents").select { |entry| entry["agent_id"] == agent }
55
+ unless snapshot["project_id"] == project && selected.size == 1 && selected.first["status"] == "ok"
56
+ raise Error, "Exact protected steering inventory is unavailable"
57
+ end
58
+ rows = selected.first.fetch("inventory").fetch("items").select do |row|
59
+ row.values_at("assignment_id", "attempt_id") == [assignment, attempt]
60
+ end
61
+ unless rows.size == 1 && rows.first["original_binding_digest"].is_a?(String) &&
62
+ rows.first["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
63
+ raise Error, "Exact original protected steering binding is unavailable"
64
+ end
65
+ # Do not compare the supplied generation with current inventory:
66
+ # an identical explicit replay retains its earlier immutable generation.
67
+ [@client_factory.call(agent, deployment), {"assignment_id" => assignment, "attempt_id" => attempt}]
68
+ rescue KeyError, TypeError, NoMethodError
69
+ raise Error, "Protected steering inventory is malformed"
70
+ end
71
+
72
+ def generation!(value)
73
+ raise Error, "Observed generation must be a positive integer" unless value.is_a?(Integer) && value.positive?
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+ require "ace/assign/organisms/prepared_work_builder"
6
+ require "ace/assign/authority/launch_driver"
7
+ require_relative "../molecules/protected_selection"
8
+ require_relative "../molecules/launch_request"
9
+ require_relative "../molecules/original_launch_child"
10
+ require_relative "protected_status"
11
+
12
+ module Ace
13
+ module Overseer
14
+ module Organisms
15
+ # Compose existing owners. Retained handles are process-local observation,
16
+ # never another authority/progress ledger or a replacement-launch grant.
17
+ class ProtectedWorkOn
18
+ attr_reader :children
19
+
20
+ def initialize(selection: nil, status: nil, builder_factory: nil, driver_factory: nil,
21
+ child_factory: nil, task_manager: nil, request_root: nil, head_reader: nil, pause: nil)
22
+ @selection = selection || Molecules::ProtectedSelection.new
23
+ @status = status || ProtectedStatus.new
24
+ @builder_factory = builder_factory || ->(root) { Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: root) }
25
+ @driver_factory = driver_factory || ->(id, deployment) { Ace::Assign::Authority::LaunchDriver.new(mapping_id: id, deployment: deployment) }
26
+ @child_factory = child_factory || -> { Molecules::OriginalLaunchChild.new }
27
+ @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
28
+ @root = File.expand_path(request_root || File.join(Dir.pwd, ".ace-local", "overseer", "launch-requests"))
29
+ @head_reader = head_reader || -> do
30
+ result = Ace::Git::Atoms::CommandExecutor.execute("git", "rev-parse", "HEAD")
31
+ raise Error, "Exact launch base is unavailable" unless result.fetch(:success)
32
+ result.fetch(:output).strip
33
+ end
34
+ @pause = pause || -> { sleep 0.1 }
35
+ @children = {}
36
+ end
37
+
38
+ def start(task_ref:, project:, agent: nil, runtime: "auto", dependency_reports: [], mutation_id: nil, base_head: nil, report:)
39
+ raise Error, "Protected launch requires Herdr; tmux cannot allocate protected work" unless %w[auto herdr].include?(runtime)
40
+ deployment, pool, visible = @selection.call(project: project, agent: agent)
41
+ ids = agent ? [agent] : visible
42
+ raise Error, "Protected project has no visible mapping" if ids.empty?
43
+ mapping = nil
44
+ ids.each do |id|
45
+ begin
46
+ preflight = @driver_factory.call(id, deployment).preflight
47
+ rescue Ace::Assign::AttemptErrors::UnauthorizedIdentity
48
+ raise if agent
49
+ next # Attributable readonly refusal; no retained/canonical effect.
50
+ end
51
+ unless preflight.is_a?(Hash) && preflight.values_at("project_id", "runtime", "supported") == [project, "herdr", true]
52
+ raise Error, "Protected Herdr capability is unavailable"
53
+ end
54
+ mapping = id
55
+ break
56
+ end
57
+ raise Error, "No visible protected mapping admits this launcher" unless mapping
58
+ task = @tasks.show(task_ref)
59
+ raise Error, "Protected leaf task is unavailable" unless task
60
+ captured_head = @head_reader.call
61
+ unless captured_head.is_a?(String) && captured_head.match?(/\A[0-9a-f]{40}\z/) && (base_head.nil? || base_head == captured_head)
62
+ raise Error, "Explicit reviewed code base differs from current source head"
63
+ end
64
+ base_head = captured_head
65
+ mutation = mutation_id || SecureRandom.hex(16)
66
+ unless mutation.is_a?(String) && mutation.match?(Molecules::LaunchRequest::MUTATION)
67
+ raise Error, "Retained invocation ID is invalid"
68
+ end
69
+ raise Error, "Original invocation already has a child" if children.key?(mutation)
70
+ FileUtils.mkdir_p(@root, mode: 0700)
71
+ owner = Molecules::LaunchRequest.new(root: @root)
72
+ prepared = @builder_factory.call(@root).call(task_ref: task.id, project_id: project, dependency_reports: dependency_reports)
73
+ raise Error, "Code base changed during prepared input capture" unless @head_reader.call == base_head
74
+ request = owner.publish(project_id: project, mapping_id: mapping, task_id: task.id, base_head: base_head, mutation_id: mutation, prepared: prepared)
75
+ owner.verify_fresh!(request)
76
+ identity = owner.identity(request)
77
+ # The caller must write and flush this mandatory identity before any
78
+ # child or canonical mutation. Reporting failure remains zero-child.
79
+ report.call(identity.merge("provisioned_capacity" => pool.size, "visible_capacity" => visible.size,
80
+ "visibility" => pool == visible ? "complete" : "partial"))
81
+ owner.verify_fresh!(request)
82
+ raise Error, "Code base changed before original child" unless @head_reader.call == base_head
83
+ child = @child_factory.call
84
+ children[mutation] = child
85
+ child.start(request: request, definition_path: owner.definition_path(request), bundle_path: identity.fetch("prepared_bundle_path"))
86
+ child.await_ready(status: @status)
87
+ {"input" => identity, "child" => child}
88
+ end
89
+
90
+ # Keep one living owner through uncertainty. Reaping the original child
91
+ # is not terminal/release proof and does not authorize display cleanup.
92
+ def serve(child)
93
+ until child.state == "exited"
94
+ child.observe
95
+ @pause.call unless child.state == "exited"
96
+ end
97
+ child
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -18,7 +18,7 @@ module Ace
18
18
  @lifecycle_exclusion = lifecycle_exclusion
19
19
  end
20
20
 
21
- def call(task_ref:, task_refs: nil, cli_preset: nil, on_progress: nil)
21
+ def call(task_ref:, task_refs: nil, cli_preset: nil, on_progress: nil, runtime: nil)
22
22
  progress = on_progress || ->(_msg) {}
23
23
 
24
24
  requested_refs = normalize_requested_refs(task_ref, task_refs)
@@ -68,10 +68,9 @@ module Ace
68
68
  end
69
69
 
70
70
  progress.call("Opening terminal window...")
71
- @window_opener.open(
72
- worktree_path: worktree[:worktree_path],
73
- preset: window_preset
74
- )
71
+ window_options = {worktree_path: worktree[:worktree_path], preset: window_preset}
72
+ window_options[:runtime] = runtime if runtime
73
+ @window_opener.open(**window_options)
75
74
 
76
75
  progress.call("Checking assignment status...")
77
76
  existing = if @assignment_detector