ace-assign 0.68.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -0
- data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/quick-implement.yml +0 -0
- data/.ace-defaults/assign/presets/release-only.yml +0 -0
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/.ace-defaults/assign/presets/work-on-docs.yml +0 -0
- data/.ace-defaults/assign/presets/work-on-task-in-place.yml +0 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +0 -0
- data/CHANGELOG.md +17 -0
- data/Rakefile +0 -0
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/fork-provider.cast +0 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +0 -0
- data/docs/demo/fork-provider.tape.yml +0 -0
- data/docs/getting-started.md +0 -0
- data/docs/handbook.md +0 -0
- data/handbook/guides/fork-context.g.md +0 -0
- data/handbook/skills/as-assign-add-task/SKILL.md +0 -0
- data/handbook/skills/as-assign-compose/SKILL.md +0 -0
- data/handbook/skills/as-assign-create/SKILL.md +0 -0
- data/handbook/skills/as-assign-drive/SKILL.md +0 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +0 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +0 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +0 -0
- data/handbook/skills/as-assign-start/SKILL.md +0 -0
- data/handbook/skills/as-create-retro-internal/SKILL.md +0 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +0 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +0 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +0 -0
- data/handbook/workflow-instructions/assign/add-task.wf.md +0 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create.wf.md +0 -0
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +0 -0
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +0 -0
- data/handbook/workflow-instructions/assign/start.wf.md +0 -0
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +0 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +0 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +0 -0
- data/lib/ace/assign/atoms/composition_rules.rb +0 -0
- data/lib/ace/assign/atoms/number_generator.rb +0 -0
- data/lib/ace/assign/atoms/preset_expander.rb +0 -0
- data/lib/ace/assign/atoms/preset_loader.rb +0 -0
- data/lib/ace/assign/atoms/preset_step_resolver.rb +0 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +0 -0
- data/lib/ace/assign/atoms/step_numbering.rb +0 -0
- data/lib/ace/assign/atoms/step_sorter.rb +0 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +0 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +2 -2
- data/lib/ace/assign/cli/commands/add.rb +0 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/list.rb +0 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +0 -0
- data/lib/ace/assign/models/assignment_info.rb +0 -0
- data/lib/ace/assign/models/queue_state.rb +0 -0
- data/lib/ace/assign/models/step.rb +0 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +0 -0
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/preset_inferrer.rb +0 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +0 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +0 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +58 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +24 -10
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require "ace/herdr/molecules/bounded_process"
|
|
7
7
|
require_relative "evidence_journal"
|
|
8
|
+
require_relative "canonical_read_commands"
|
|
8
9
|
require_relative "../authority/private_directory"
|
|
9
10
|
|
|
10
11
|
module Ace
|
|
@@ -13,6 +14,7 @@ module Ace
|
|
|
13
14
|
# Isolates read-only Git from an installed authority's live configuration.
|
|
14
15
|
# The copied view never publishes evidence or mutates the source journal.
|
|
15
16
|
class CanonicalReadSnapshot
|
|
17
|
+
include CanonicalReadCommands
|
|
16
18
|
class Unavailable < AttemptErrors::EvidenceUnavailable; end
|
|
17
19
|
BYTE_LIMIT = 256 * 1_048_576
|
|
18
20
|
ENTRY_LIMIT = 4096
|
|
@@ -94,60 +96,6 @@ module Ace
|
|
|
94
96
|
end
|
|
95
97
|
private :with_snapshot
|
|
96
98
|
|
|
97
|
-
attr_reader :commit
|
|
98
|
-
|
|
99
|
-
# EvidenceJournal uses this only as its existing read command boundary.
|
|
100
|
-
def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
|
|
101
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
102
|
-
unless read_command?(argv, stdin_data)
|
|
103
|
-
refuse!("write_or_unsupported_command")
|
|
104
|
-
end
|
|
105
|
-
result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
|
|
106
|
-
[result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def blob_batch(entries, output_limit:)
|
|
110
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
111
|
-
unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
112
|
-
refuse!("unsupported_command")
|
|
113
|
-
end
|
|
114
|
-
result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
|
|
115
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
116
|
-
result.stdout.to_s.b
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def history_diff(commits, paths, output_limit:)
|
|
120
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
121
|
-
unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
|
|
122
|
-
commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
|
|
123
|
-
paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
|
|
124
|
-
(paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
|
|
125
|
-
output_limit.is_a?(Integer) && output_limit.positive?
|
|
126
|
-
refuse!("unsupported_command")
|
|
127
|
-
end
|
|
128
|
-
result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
|
|
129
|
-
--no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
|
|
130
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
131
|
-
result.stdout.to_s.b
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def blob_sizes(oids)
|
|
135
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
136
|
-
unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
137
|
-
refuse!("unsupported_command")
|
|
138
|
-
end
|
|
139
|
-
run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def blob(path, commit:, max_bytes:)
|
|
143
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
144
|
-
unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
145
|
-
refuse!("unsupported_command")
|
|
146
|
-
end
|
|
147
|
-
result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
|
|
148
|
-
result.stdout.to_s.b
|
|
149
|
-
end
|
|
150
|
-
|
|
151
99
|
private
|
|
152
100
|
|
|
153
101
|
def refuse!(reason)
|
|
@@ -352,15 +300,6 @@ module Ace
|
|
|
352
300
|
File.binwrite(ref, "#{@commit}\n")
|
|
353
301
|
end
|
|
354
302
|
|
|
355
|
-
def read_command?(argv, stdin)
|
|
356
|
-
return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
|
|
357
|
-
allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
|
|
358
|
-
"rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
|
|
359
|
-
"show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
|
|
360
|
-
flags = allowed[argv.first]
|
|
361
|
-
flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
|
|
362
|
-
end
|
|
363
|
-
|
|
364
303
|
def run!(argv, stdin_data: "", limit:)
|
|
365
304
|
result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
|
|
366
305
|
environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
|
|
@@ -62,6 +62,7 @@ module Ace
|
|
|
62
62
|
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
63
63
|
evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
|
|
64
64
|
unless %i[local protected].include?(mode) &&
|
|
65
|
+
(evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
|
|
65
66
|
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
66
67
|
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
67
68
|
end
|
|
@@ -128,7 +129,7 @@ module Ace
|
|
|
128
129
|
paths.each do |path|
|
|
129
130
|
parts = path.split("/", -1)
|
|
130
131
|
event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
|
|
131
|
-
request = parts.size == 3 &&
|
|
132
|
+
request = parts.size == 3 && [%w[execution requests], %w[execution merge-requests]].include?(parts[0, 2]) && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
|
|
132
133
|
# Definitions, prepared packets, candidate bundles and imports
|
|
133
134
|
# retain the SAME mutation owner's closed immutable blob dialect.
|
|
134
135
|
validate_blob_path!(path) unless event || request
|
|
@@ -138,6 +139,12 @@ module Ace
|
|
|
138
139
|
unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
|
|
139
140
|
raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
|
|
140
141
|
end
|
|
142
|
+
fixed_records = fixed_merge_request_records(commit: value)
|
|
143
|
+
expected_fixed = paths.select { |path| path.start_with?("execution/merge-requests/") }.map { |path| File.basename(path, ".json") }.sort
|
|
144
|
+
unless fixed_records.map { |record| record.fetch("request_id") }.sort == expected_fixed
|
|
145
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization fixed merge inventory differs"
|
|
146
|
+
end
|
|
147
|
+
fixed_records.each { |record| verify_fixed_record!(record, commit: value) }
|
|
141
148
|
current = File.stat(@repo_root)
|
|
142
149
|
unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
|
|
143
150
|
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
|
|
@@ -201,6 +208,21 @@ module Ace
|
|
|
201
208
|
event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
|
|
202
209
|
end
|
|
203
210
|
|
|
211
|
+
# Cross-attempt projections use canonical publication order, never the
|
|
212
|
+
# wall-clock timestamps of independently chained attempts. Exactly one
|
|
213
|
+
# selected event must have been introduced by the latest relevant CAS.
|
|
214
|
+
def latest_event!(assignment_id:, event_digests:, commit:)
|
|
215
|
+
introductions = event_commits!(assignment_id: assignment_id, event_digests: event_digests, commit: commit)
|
|
216
|
+
introduced_commits = introductions.values.to_h { |value| [value, true] }
|
|
217
|
+
node = canonical_history_nodes!(commit).find { |entry| introduced_commits.key?(entry.first) }
|
|
218
|
+
selected = node && introductions.select { |_digest, introduced| introduced == node.first }.keys
|
|
219
|
+
unless selected&.one?
|
|
220
|
+
raise AttemptErrors::EvidenceUnavailable, "latest canonical event is missing or ambiguous"
|
|
221
|
+
end
|
|
222
|
+
read_events(assignment_id, commit: commit).find { |event| event.fetch("digest") == selected.first } ||
|
|
223
|
+
raise(AttemptErrors::EvidenceUnavailable, "latest canonical event is unavailable")
|
|
224
|
+
end
|
|
225
|
+
|
|
204
226
|
# One immutable walk for an operation selecting several assignments.
|
|
205
227
|
# Every selected assignment retains its complete chain and exact raw
|
|
206
228
|
# blob proof; the result is returned, never cached on this owner.
|
|
@@ -760,7 +782,9 @@ module Ace
|
|
|
760
782
|
%w[succeeded failed failed-settled].include?(state)
|
|
761
783
|
end
|
|
762
784
|
|
|
763
|
-
|
|
785
|
+
# Shared schema/binding checks; storage interpretation belongs to the
|
|
786
|
+
# caller's source-selected evidence owner, not this pure validator.
|
|
787
|
+
def validate_terminal_receipt_structure!(current, state, receipt)
|
|
764
788
|
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
765
789
|
raise AttemptErrors::ReceiptRejected,
|
|
766
790
|
"Service terminal receipt has invalid fields"
|
|
@@ -787,7 +811,14 @@ module Ace
|
|
|
787
811
|
raise AttemptErrors::ReceiptRejected,
|
|
788
812
|
"Service settlement requires a no-effect attestation artifact"
|
|
789
813
|
end
|
|
790
|
-
|
|
814
|
+
evidence_items
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def validate_terminal_receipt!(current, state, receipt, pending: nil)
|
|
818
|
+
evidence_items = validate_terminal_receipt_structure!(current, state, receipt)
|
|
819
|
+
# Canonical byte ownership is also usable by the ordinary installed
|
|
820
|
+
# service. It does not opt that service into protected authority.
|
|
821
|
+
if @evidence_reader
|
|
791
822
|
if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
|
|
792
823
|
(current["operation"] == "publish" && %w[succeeded failed].include?(state))
|
|
793
824
|
# The same installed evidence reader owns the entire imported
|
|
@@ -922,6 +953,9 @@ module Ace
|
|
|
922
953
|
def authorization_conflict(binding)
|
|
923
954
|
authorization = binding["authorization"]
|
|
924
955
|
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
956
|
+
if authorization.start_with?("proposal-")
|
|
957
|
+
return proposal_claim_conflict(authorization, request_id: binding.fetch("request_id"), fixed: false)
|
|
958
|
+
end
|
|
925
959
|
service_request_records.find do |other|
|
|
926
960
|
next false if other["request_id"] == binding.fetch("request_id")
|
|
927
961
|
next false if other["state"] == "rejected" && other["consumed"] == false
|
|
@@ -994,8 +1028,8 @@ module Ace
|
|
|
994
1028
|
#
|
|
995
1029
|
# @param assignment_id [String] Assignment ID
|
|
996
1030
|
# @return [Array<Models::Attempt>] Derived attempts
|
|
997
|
-
def derived_attempts(assignment_id)
|
|
998
|
-
events = read_events(assignment_id)
|
|
1031
|
+
def derived_attempts(assignment_id, commit: ref_value)
|
|
1032
|
+
events = read_events(assignment_id, commit: commit)
|
|
999
1033
|
by_attempt = events.group_by { |event| event["attempt_id"] }
|
|
1000
1034
|
by_attempt.delete(nil)
|
|
1001
1035
|
|
|
@@ -1006,7 +1040,7 @@ module Ace
|
|
|
1006
1040
|
state = derive_state(attempt_events)
|
|
1007
1041
|
next if state.nil?
|
|
1008
1042
|
|
|
1009
|
-
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
|
|
1043
|
+
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
|
|
1010
1044
|
end
|
|
1011
1045
|
end
|
|
1012
1046
|
|
|
@@ -1271,7 +1305,7 @@ module Ace
|
|
|
1271
1305
|
# opaque reference is receiver-issued, never caller-supplied, and
|
|
1272
1306
|
# replays return the originally issued one.
|
|
1273
1307
|
FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
|
|
1274
|
-
queue_entry operation authorization input_digest request principal receiver_id
|
|
1308
|
+
queue_entry operation authorization input_digest request principal caller_uid receiver_id
|
|
1275
1309
|
configuration_sha256 execution_identity].freeze
|
|
1276
1310
|
FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
|
|
1277
1311
|
|
|
@@ -1324,25 +1358,27 @@ module Ace
|
|
|
1324
1358
|
|
|
1325
1359
|
# Claim the start of the fixed effect: pending -> uncertain in one
|
|
1326
1360
|
# CAS transition, so a receiver restart can never dispatch a second
|
|
1327
|
-
# merge for the same admitted record
|
|
1328
|
-
#
|
|
1361
|
+
# merge for the same admitted record. An already-started claim refuses;
|
|
1362
|
+
# only the winning effect may later settle it.
|
|
1329
1363
|
def begin_fixed_merge_request(request_id)
|
|
1330
1364
|
current = fixed_merge_request(request_id)
|
|
1331
1365
|
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1332
|
-
|
|
1366
|
+
if current["state"] == "uncertain"
|
|
1367
|
+
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
|
|
1368
|
+
end
|
|
1333
1369
|
unless current["state"] == "pending"
|
|
1334
1370
|
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1335
1371
|
end
|
|
1336
1372
|
update_fixed_merge_request(request_id, expected: current,
|
|
1337
1373
|
replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
|
|
1338
|
-
event_type: "merge_transition")
|
|
1374
|
+
event_type: "merge_transition", guard: -> { verify_fixed_proposal!(current) })
|
|
1339
1375
|
end
|
|
1340
1376
|
|
|
1341
1377
|
# Terminal settlement: succeeded/failed with the exact receipt
|
|
1342
1378
|
# validated HERE at the journal boundary — structure, the closed
|
|
1343
1379
|
# terminal invariants, and the binding against the current record.
|
|
1344
1380
|
# Failure is terminal only with immutable no-effect evidence.
|
|
1345
|
-
def settle_fixed_merge_request(request_id, terminal_status:, receipt:)
|
|
1381
|
+
def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
|
|
1346
1382
|
unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
|
|
1347
1383
|
raise ArgumentError, "invalid fixed merge terminal status"
|
|
1348
1384
|
end
|
|
@@ -1351,17 +1387,40 @@ module Ace
|
|
|
1351
1387
|
unless %w[pending uncertain].include?(current["state"])
|
|
1352
1388
|
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1353
1389
|
end
|
|
1390
|
+
evidence_files = {}
|
|
1391
|
+
if terminal_status == "failed"
|
|
1392
|
+
unless attestation.is_a?(Hash) &&
|
|
1393
|
+
attestation.values_at("request_id", "input_digest", "reference", "outcome") ==
|
|
1394
|
+
current.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1395
|
+
attestation["candidate_head"] == current.dig("request", "candidate_head") &&
|
|
1396
|
+
attestation["expected_base_head"] == current.dig("request", "expected_base_head")
|
|
1397
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge no_effect_evidence requires a bound canonical attestation"
|
|
1398
|
+
end
|
|
1399
|
+
content = JSON.generate(attestation)
|
|
1400
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge attestation exceeds bound" if content.bytesize > 16_384
|
|
1401
|
+
path = fixed_no_effect_path(request_id)
|
|
1402
|
+
evidence_files[path] = content
|
|
1403
|
+
receipt = receipt.merge("effect" => receipt.fetch("effect").merge(
|
|
1404
|
+
"no_effect_evidence" => {"reference" => path, "sha256" => Digest::SHA256.hexdigest(content)}))
|
|
1405
|
+
elsif attestation
|
|
1406
|
+
raise AttemptErrors::ReceiptRejected, "Successful merge cannot carry no-effect attestation"
|
|
1407
|
+
end
|
|
1354
1408
|
validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1355
1409
|
update_fixed_merge_request(request_id, expected: current,
|
|
1356
1410
|
replacement: current.merge("state" => terminal_status, "receipt" => receipt,
|
|
1357
1411
|
"finished_at" => Time.now.utc.iso8601(9)),
|
|
1358
|
-
event_type: "merge_transition")
|
|
1412
|
+
event_type: "merge_transition", evidence_files: evidence_files)
|
|
1359
1413
|
end
|
|
1360
1414
|
|
|
1361
1415
|
def fixed_merge_request_path(request_id)
|
|
1362
1416
|
"execution/merge-requests/#{request_id}.json"
|
|
1363
1417
|
end
|
|
1364
1418
|
|
|
1419
|
+
def fixed_no_effect_path(request_id)
|
|
1420
|
+
validate_request_id!(request_id)
|
|
1421
|
+
"execution/merge-evidence/#{request_id}.json"
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1365
1424
|
def validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1366
1425
|
Atoms::FixedMergeSchemas.validate_receipt!(receipt)
|
|
1367
1426
|
Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
|
|
@@ -1388,25 +1447,39 @@ module Ace
|
|
|
1388
1447
|
end
|
|
1389
1448
|
end
|
|
1390
1449
|
|
|
1391
|
-
def fixed_no_effect_intact?(record)
|
|
1450
|
+
def fixed_no_effect_intact?(record, commit: ref_value)
|
|
1392
1451
|
receipt = record["receipt"]
|
|
1393
1452
|
return false unless receipt.is_a?(Hash)
|
|
1394
1453
|
evidence = receipt.dig("effect", "no_effect_evidence")
|
|
1395
1454
|
return false unless evidence.is_a?(Hash)
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
Digest::SHA256.file(real).hexdigest == evidence.fetch("sha256")
|
|
1405
|
-
rescue Errno::ENOENT, AttemptErrors::EvidenceUnavailable
|
|
1455
|
+
path = fixed_no_effect_path(record.fetch("request_id"))
|
|
1456
|
+
return false unless evidence["reference"] == path
|
|
1457
|
+
content = bounded_blob(path, commit: commit, max_bytes: 16_384)
|
|
1458
|
+
proof = JSON.parse(content)
|
|
1459
|
+
Digest::SHA256.hexdigest(content) == evidence["sha256"] &&
|
|
1460
|
+
proof.values_at("request_id", "input_digest", "reference", "outcome") == record.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1461
|
+
proof["candidate_head"] == record.dig("request", "candidate_head") && proof["expected_base_head"] == record.dig("request", "expected_base_head")
|
|
1462
|
+
rescue AttemptErrors::EvidenceUnavailable, JSON::ParserError, NoMethodError
|
|
1406
1463
|
false
|
|
1407
1464
|
end
|
|
1408
1465
|
|
|
1409
|
-
|
|
1466
|
+
# Immutable terminal consumption for ordinary completion. It uses the
|
|
1467
|
+
# same receipt and no-effect owners as admission/authorization release.
|
|
1468
|
+
def fixed_merge_settlement!(request_id, commit: ref_value)
|
|
1469
|
+
record = fixed_merge_request(request_id, commit: commit)
|
|
1470
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge is missing" unless record
|
|
1471
|
+
verify_fixed_record!(record, commit: commit)
|
|
1472
|
+
unless %w[succeeded failed].include?(record["state"])
|
|
1473
|
+
raise AttemptErrors::ServiceSettlementPending, "canonical fixed merge is unresolved"
|
|
1474
|
+
end
|
|
1475
|
+
validate_fixed_receipt!(record, record.fetch("state"), record.fetch("receipt"))
|
|
1476
|
+
if record["state"] == "failed" && !fixed_no_effect_intact?(record, commit: commit)
|
|
1477
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge no-effect proof differs"
|
|
1478
|
+
end
|
|
1479
|
+
record
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil, evidence_files: {})
|
|
1410
1483
|
unless %w[merge_admission merge_transition].include?(event_type)
|
|
1411
1484
|
raise ArgumentError, "invalid fixed merge event type"
|
|
1412
1485
|
end
|
|
@@ -1419,10 +1492,8 @@ module Ace
|
|
|
1419
1492
|
prepared = prepare_fixed_update(request_id: request_id, expected: expected,
|
|
1420
1493
|
replacement: replacement, event_type: event_type, guard: guard)
|
|
1421
1494
|
return prepared.fetch(:record) if prepared[:replayed]
|
|
1495
|
+
write_mutation_blobs(evidence_files, commit: old)
|
|
1422
1496
|
path = fixed_merge_request_path(request_id)
|
|
1423
|
-
full = File.join(checkout_dir, path)
|
|
1424
|
-
FileUtils.mkdir_p(File.dirname(full))
|
|
1425
|
-
File.write(full, JSON.pretty_generate(prepared.fetch(:record)))
|
|
1426
1497
|
assignment_id = replacement.fetch("assignment_id")
|
|
1427
1498
|
attempt_id = replacement.fetch("attempt_id")
|
|
1428
1499
|
prior = read_events(assignment_id).reverse
|
|
@@ -1430,7 +1501,8 @@ module Ace
|
|
|
1430
1501
|
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
1431
1502
|
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
1432
1503
|
write_event_files(assignment_id, [event])
|
|
1433
|
-
git!("-C", checkout_dir, "add", "--",
|
|
1504
|
+
git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
|
|
1505
|
+
stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
|
|
1434
1506
|
state = replacement.fetch("state")
|
|
1435
1507
|
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1436
1508
|
"commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
|
|
@@ -1477,6 +1549,13 @@ module Ace
|
|
|
1477
1549
|
end
|
|
1478
1550
|
conflict = guard&.call
|
|
1479
1551
|
raise_authorization_conflict!(conflict) if conflict
|
|
1552
|
+
if expected.nil? || replacement["state"] == "uncertain"
|
|
1553
|
+
attempt = derived_attempts(replacement.fetch("assignment_id")).find { |item| item.attempt_id == replacement.fetch("attempt_id") }
|
|
1554
|
+
unless attempt&.managed? && attempt.active? && attempt.binding.project_id == replacement.fetch("project_id")
|
|
1555
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge admission requires its current active canonical attempt"
|
|
1556
|
+
end
|
|
1557
|
+
end
|
|
1558
|
+
verify_fixed_proposal!(replacement) if event_type == "merge_admission"
|
|
1480
1559
|
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1481
1560
|
"input_digest" => replacement.fetch("input_digest"),
|
|
1482
1561
|
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
@@ -1485,6 +1564,21 @@ module Ace
|
|
|
1485
1564
|
end
|
|
1486
1565
|
private :prepare_fixed_update
|
|
1487
1566
|
|
|
1567
|
+
def verify_fixed_proposal!(record)
|
|
1568
|
+
return unless record.fetch("authorization").start_with?("proposal-")
|
|
1569
|
+
proposal_authorize!(record.fetch("authorization"), fixed_proposal_binding(record))
|
|
1570
|
+
conflict = proposal_claim_conflict(record.fetch("authorization"),
|
|
1571
|
+
request_id: record.fetch("request_id"), fixed: true)
|
|
1572
|
+
raise AttemptErrors::AuthorizationConflict, "Proposal already holds an effect claim" if conflict
|
|
1573
|
+
nil
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
def fixed_proposal_binding(record)
|
|
1577
|
+
record.slice("operation", "project_id", "assignment_id", "attempt_id", "input_digest", "caller_uid").merge(
|
|
1578
|
+
"target" => {"resource" => record.fetch("request").fetch("pr_url"), "artifact_digest" => nil},
|
|
1579
|
+
"candidate_head" => record.fetch("request").fetch("candidate_head"))
|
|
1580
|
+
end
|
|
1581
|
+
|
|
1488
1582
|
def fixed_replay?(existing, replacement)
|
|
1489
1583
|
FIXED_MERGE_BINDING_FIELDS.all? do |field|
|
|
1490
1584
|
existing[field] == replacement.fetch(field)
|
|
@@ -1514,7 +1608,8 @@ module Ace
|
|
|
1514
1608
|
# The module marks a private region above; the fixed merge primitive
|
|
1515
1609
|
# is a public coordinator-facing surface, so it is re-published here.
|
|
1516
1610
|
public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
|
|
1517
|
-
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict
|
|
1611
|
+
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
|
|
1612
|
+
:verify_service_attestation!
|
|
1518
1613
|
|
|
1519
1614
|
# Latest lifecycle state implied by the events, or nil when the
|
|
1520
1615
|
# events do not describe a full attempt (intent missing).
|
|
@@ -1522,7 +1617,7 @@ module Ace
|
|
|
1522
1617
|
CanonicalAttemptState.derive(events)
|
|
1523
1618
|
end
|
|
1524
1619
|
|
|
1525
|
-
def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
|
|
1620
|
+
def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
|
|
1526
1621
|
process_start = events.reverse.find do |event|
|
|
1527
1622
|
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
1528
1623
|
end
|
|
@@ -1552,7 +1647,7 @@ module Ace
|
|
|
1552
1647
|
binding: binding,
|
|
1553
1648
|
state: state,
|
|
1554
1649
|
candidate_head: candidate_head,
|
|
1555
|
-
journal_commit:
|
|
1650
|
+
journal_commit: commit
|
|
1556
1651
|
)
|
|
1557
1652
|
end
|
|
1558
1653
|
|
|
@@ -1658,7 +1753,7 @@ module Ace
|
|
|
1658
1753
|
File.join(@checkout_root, ".evidence.lock")
|
|
1659
1754
|
end
|
|
1660
1755
|
|
|
1661
|
-
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
|
|
1756
|
+
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
|
|
1662
1757
|
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
|
|
1663
1758
|
FileUtils.mkdir_p(@checkout_root)
|
|
1664
1759
|
lock = if owner_uid
|
|
@@ -1667,7 +1762,19 @@ module Ace
|
|
|
1667
1762
|
File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
1668
1763
|
end
|
|
1669
1764
|
begin
|
|
1670
|
-
|
|
1765
|
+
if deadline
|
|
1766
|
+
unless deadline.is_a?(Numeric) && deadline.finite?
|
|
1767
|
+
raise ArgumentError, "canonical lock deadline must be finite"
|
|
1768
|
+
end
|
|
1769
|
+
loop do
|
|
1770
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1771
|
+
raise AttemptErrors::Conflict, "canonical journal lock deadline elapsed" unless remaining.positive?
|
|
1772
|
+
break if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
1773
|
+
sleep [remaining, 0.01].min
|
|
1774
|
+
end
|
|
1775
|
+
else
|
|
1776
|
+
lock.flock(File::LOCK_EX)
|
|
1777
|
+
end
|
|
1671
1778
|
begin
|
|
1672
1779
|
yield
|
|
1673
1780
|
rescue
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/herdr/molecules/bounded_process"
|
|
4
|
+
require_relative "evidence_journal"
|
|
5
|
+
require_relative "canonical_read_commands"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Read the installed owner's immutable Git objects without opening its
|
|
11
|
+
# private writer lock/cache, copying the repository, or contacting it.
|
|
12
|
+
# Source selection is supplied only by verified installation policy.
|
|
13
|
+
class InstalledJournalRead
|
|
14
|
+
include CanonicalReadCommands
|
|
15
|
+
ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
|
|
16
|
+
"GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
|
|
17
|
+
"GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
|
|
18
|
+
"GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
|
|
19
|
+
"GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
|
|
20
|
+
ENTRY_LIMIT = 100_000
|
|
21
|
+
|
|
22
|
+
def initialize(repo_root:, ref:, owner_uid:)
|
|
23
|
+
unless repo_root.is_a?(String) && repo_root.start_with?("/") &&
|
|
24
|
+
!repo_root.include?("\0") && File.expand_path(repo_root) == repo_root &&
|
|
25
|
+
ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z}) &&
|
|
26
|
+
owner_uid.is_a?(Integer) && owner_uid.positive?
|
|
27
|
+
raise ArgumentError, "installed journal selection is malformed"
|
|
28
|
+
end
|
|
29
|
+
@repo_root, @ref, @owner_uid = repo_root.dup.freeze, ref.dup.freeze, owner_uid
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with(deadline:)
|
|
34
|
+
refuse!("read_busy") unless @mutex.try_lock
|
|
35
|
+
begin
|
|
36
|
+
@deadline = deadline
|
|
37
|
+
remaining
|
|
38
|
+
verify_source!
|
|
39
|
+
@view = File.join(@repo_root, ".git")
|
|
40
|
+
@commit = run!(["rev-parse", "--verify", @ref], limit: 128).stdout.strip
|
|
41
|
+
refuse!("ref_unavailable") unless @commit.match?(OID)
|
|
42
|
+
result = yield self
|
|
43
|
+
verify_source!
|
|
44
|
+
result
|
|
45
|
+
ensure
|
|
46
|
+
@view = @commit = @deadline = nil
|
|
47
|
+
@mutex.unlock
|
|
48
|
+
end
|
|
49
|
+
rescue SystemCallError, IOError, Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
50
|
+
refuse!("source_unavailable")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def refuse!(reason)
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "installed journal read unavailable: #{reason}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remaining
|
|
60
|
+
refuse!("deadline") unless @deadline.is_a?(Numeric) && @deadline.finite?
|
|
61
|
+
value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
refuse!("deadline") unless value.positive?
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_source!
|
|
67
|
+
# Caller-writable ancestors can replace even a correctly owned leaf.
|
|
68
|
+
current = File.join(@repo_root, ".git")
|
|
69
|
+
loop do
|
|
70
|
+
stat = File.lstat(current)
|
|
71
|
+
unless stat.directory? && !stat.symlink? && [0, @owner_uid].include?(stat.uid) &&
|
|
72
|
+
(stat.mode & 0o022).zero?
|
|
73
|
+
refuse!("storage_owner")
|
|
74
|
+
end
|
|
75
|
+
if [@repo_root, File.join(@repo_root, ".git")].include?(current) && stat.uid != @owner_uid
|
|
76
|
+
refuse!("repository_owner")
|
|
77
|
+
end
|
|
78
|
+
break if current == "/"
|
|
79
|
+
current = File.dirname(current)
|
|
80
|
+
end
|
|
81
|
+
%w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
|
|
82
|
+
path = File.join(@repo_root, ".git", name)
|
|
83
|
+
refuse!("redirected_storage") if File.exist?(path) || File.symlink?(path)
|
|
84
|
+
end
|
|
85
|
+
@entries = 0
|
|
86
|
+
%w[objects refs config HEAD packed-refs].each do |name|
|
|
87
|
+
path = File.join(@repo_root, ".git", name)
|
|
88
|
+
next if %w[HEAD packed-refs].include?(name) && !File.exist?(path) && !File.symlink?(path)
|
|
89
|
+
verify_tree!(path)
|
|
90
|
+
end
|
|
91
|
+
verify_config!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_tree!(path)
|
|
95
|
+
remaining
|
|
96
|
+
@entries += 1
|
|
97
|
+
refuse!("storage_inventory_limit") if @entries > ENTRY_LIMIT
|
|
98
|
+
stat = File.lstat(path)
|
|
99
|
+
unless !stat.symlink? && (stat.file? || stat.directory?) &&
|
|
100
|
+
[0, @owner_uid].include?(stat.uid) && (stat.mode & 0o022).zero?
|
|
101
|
+
refuse!("storage_owner")
|
|
102
|
+
end
|
|
103
|
+
Dir.each_child(path) { |name| verify_tree!(File.join(path, name)) } if stat.directory?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def verify_config!
|
|
107
|
+
bytes = File.open(File.join(@repo_root, ".git/config"), File::RDONLY | File::NOFOLLOW) { |io| io.read(65_537) }
|
|
108
|
+
refuse!("config_limit") if bytes.bytesize > 65_536
|
|
109
|
+
result = Herdr::Molecules::BoundedProcess.call(
|
|
110
|
+
["/usr/bin/git", "config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
|
|
111
|
+
environment: ENVIRONMENT, chdir: "/", stdin_data: bytes,
|
|
112
|
+
timeout_s: remaining, output_limit: 65_536)
|
|
113
|
+
refuse!("config_unavailable") unless result.status.success? && !result.oversized
|
|
114
|
+
result.stdout.split("\0").each do |entry|
|
|
115
|
+
key, value = entry.split("\n", 2)
|
|
116
|
+
if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
|
|
117
|
+
key == "core.repositoryformatversion" && value != "0"
|
|
118
|
+
refuse!("redirected_configuration")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def read_result(argv, **options)
|
|
124
|
+
run!(argv, **options, allow_failure: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run!(argv, stdin_data: "", limit:, allow_failure: false)
|
|
128
|
+
# All ref-based reads in one observation use the same immutable tip.
|
|
129
|
+
selected = argv.map { |arg| arg == @ref && @commit ? @commit : arg }
|
|
130
|
+
result = Herdr::Molecules::BoundedProcess.call([
|
|
131
|
+
"/usr/bin/git", "--git-dir=#{@view}", "--no-pager",
|
|
132
|
+
"-c", "safe.directory=#{@repo_root}", "-c", "core.hooksPath=/dev/null",
|
|
133
|
+
"-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *selected],
|
|
134
|
+
environment: ENVIRONMENT, chdir: @repo_root, stdin_data: stdin_data,
|
|
135
|
+
timeout_s: remaining, output_limit: [limit, BYTE_LIMIT].min, stderr_limit: 65_536)
|
|
136
|
+
refuse!("resource_limit") if result.oversized
|
|
137
|
+
refuse!("git_unavailable") unless allow_failure || result.status.success?
|
|
138
|
+
result
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -34,7 +34,7 @@ module Ace
|
|
|
34
34
|
# returns events [{type:, payload:}], immutable blobs, and public data.
|
|
35
35
|
# Only the authority calls this internal journal API; wire requests
|
|
36
36
|
# cannot select blob paths, event types, or accepted response data.
|
|
37
|
-
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil)
|
|
37
|
+
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil, deadline: nil, replay_authorizer: nil)
|
|
38
38
|
unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
|
|
39
39
|
(generation_mode == :recorded_completion && %w[complete_service complete_no_effect].include?(operation) && expected_generation.nil?) ||
|
|
40
40
|
(generation_mode == :prompt_completion && operation == "prompt_attempt" && expected_generation.nil? && prompt_completion.is_a?(Hash)) ||
|
|
@@ -71,7 +71,8 @@ module Ace
|
|
|
71
71
|
unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
72
72
|
raise ArgumentError, "invalid mutation parameter digest"
|
|
73
73
|
end
|
|
74
|
-
|
|
74
|
+
raise ArgumentError, "replay authorizer must be source owned" if replay_authorizer && !replay_authorizer.respond_to?(:call)
|
|
75
|
+
with_lock(deadline: deadline) do
|
|
75
76
|
self.class::CAS_ATTEMPTS.times do
|
|
76
77
|
old = ref_value
|
|
77
78
|
ensure_checkout!
|
|
@@ -96,6 +97,7 @@ module Ace
|
|
|
96
97
|
raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
|
|
97
98
|
end
|
|
98
99
|
result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
|
|
100
|
+
replay_authorizer&.call
|
|
99
101
|
return with_replay ? {data: result, replayed: true} : result
|
|
100
102
|
end
|
|
101
103
|
current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
|
|
@@ -215,7 +217,7 @@ module Ace
|
|
|
215
217
|
end
|
|
216
218
|
|
|
217
219
|
def validate_blob_path!(path)
|
|
218
|
-
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
220
|
+
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|execution/merge-evidence|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
219
221
|
!%w[. ..].include?(File.basename(path))
|
|
220
222
|
raise ArgumentError, "invalid canonical blob reference"
|
|
221
223
|
end
|
|
@@ -214,14 +214,25 @@ module Ace
|
|
|
214
214
|
# given order, released in reverse — callers must use one consistent
|
|
215
215
|
# global order, task before assignment, to avoid deadlock). Fails
|
|
216
216
|
# closed when any identity was pruned.
|
|
217
|
-
def with_shared_multi(keys, reset_removed: false)
|
|
217
|
+
def with_shared_multi(keys, reset_removed: false, deadline: nil)
|
|
218
218
|
ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
|
|
219
219
|
raise ArgumentError, "exclusion keys are required" if ordered.empty?
|
|
220
220
|
|
|
221
221
|
locks = []
|
|
222
222
|
begin
|
|
223
|
+
maintenance_deadline!(deadline) if deadline
|
|
223
224
|
ordered.each { |key| locks << open_lock(key) }
|
|
224
|
-
locks.each
|
|
225
|
+
locks.each do |lock|
|
|
226
|
+
if deadline
|
|
227
|
+
until lock.flock(File::LOCK_SH | File::LOCK_NB)
|
|
228
|
+
maintenance_deadline!(deadline)
|
|
229
|
+
sleep 0.01
|
|
230
|
+
end
|
|
231
|
+
maintenance_deadline!(deadline)
|
|
232
|
+
else
|
|
233
|
+
lock.flock(File::LOCK_SH)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
225
236
|
ordered.each do |key|
|
|
226
237
|
if removed?(key)
|
|
227
238
|
unless reset_removed
|