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.
- checksums.yaml +4 -4
- data/.ace-defaults/nav/protocols/wfi-sources/ace-overseer.yml +1 -1
- data/CHANGELOG.md +27 -0
- data/README.md +14 -14
- data/docs/usage.md +66 -41
- data/handbook/workflow-instructions/overseer.wf.md +13 -9
- data/handbook/workflow-instructions/roles/lab-coordinator.wf.md +99 -0
- data/handbook/workflow-instructions/roles/project-overseer.wf.md +103 -0
- data/handbook/workflow-instructions/roles/second-commander.wf.md +93 -0
- data/lib/ace/overseer/cli/commands/agents.rb +12 -7
- data/lib/ace/overseer/cli/commands/projects.rb +11 -7
- data/lib/ace/overseer/cli/commands/prompt.rb +44 -16
- data/lib/ace/overseer/cli/commands/prune.rb +51 -123
- data/lib/ace/overseer/cli/commands/review.rb +28 -8
- data/lib/ace/overseer/cli/commands/status.rb +33 -19
- data/lib/ace/overseer/cli/commands/stop.rb +18 -8
- data/lib/ace/overseer/cli/commands/work_on.rb +52 -16
- data/lib/ace/overseer/cli.rb +6 -9
- data/lib/ace/overseer/molecules/launch_request.rb +216 -0
- data/lib/ace/overseer/molecules/original_launch_child.rb +165 -0
- data/lib/ace/overseer/molecules/proposal_tick.rb +37 -9
- data/lib/ace/overseer/molecules/protected_selection.rb +46 -0
- data/lib/ace/overseer/molecules/window_opener.rb +9 -2
- data/lib/ace/overseer/organisms/launch_recovery.rb +65 -0
- data/lib/ace/overseer/organisms/protected_prune.rb +116 -0
- data/lib/ace/overseer/organisms/protected_review.rb +167 -0
- data/lib/ace/overseer/organisms/protected_status.rb +157 -0
- data/lib/ace/overseer/organisms/protected_steering.rb +78 -0
- data/lib/ace/overseer/organisms/protected_work_on.rb +102 -0
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +4 -5
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +1 -2
- metadata +50 -13
- data/lib/ace/overseer/cli/commands/prepare.rb +0 -38
- data/lib/ace/overseer/molecules/lab_client.rb +0 -56
- 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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|