ace-assign 0.70.0 → 0.73.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.
Files changed (181) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +3 -2099
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -1
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  36. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  37. data/lib/ace/assign/version.rb +1 -1
  38. data/lib/ace/assign.rb +0 -6
  39. metadata +4 -161
  40. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  41. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  42. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  43. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  44. data/docs/protected-authority.md +0 -282
  45. data/exe/ace-scope-ready +0 -55
  46. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  47. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  48. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  49. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  50. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  51. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  52. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  53. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  54. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  55. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  56. data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
  57. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  58. data/lib/ace/assign/authority/client.rb +0 -257
  59. data/lib/ace/assign/authority/deployment.rb +0 -627
  60. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  61. data/lib/ace/assign/authority/endcap.rb +0 -455
  62. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  63. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  64. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  65. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  66. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  67. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  68. data/lib/ace/assign/authority/endcap_publication.rb +0 -207
  69. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  70. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  71. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  72. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  73. data/lib/ace/assign/authority/endcap_services.rb +0 -598
  74. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  75. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  76. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  77. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  78. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  79. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  80. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  81. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  82. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  83. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  84. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  85. data/lib/ace/assign/authority/launch_review.rb +0 -91
  86. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  87. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  88. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  89. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  90. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  91. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  92. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  93. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  94. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  95. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  96. data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
  97. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  98. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
  99. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  100. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  101. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  102. data/lib/ace/assign/authority/ordinary_results.rb +0 -196
  103. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  104. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  105. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  106. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  107. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  108. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  109. data/lib/ace/assign/authority/prepared_work.rb +0 -304
  110. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  111. data/lib/ace/assign/authority/private_directory.rb +0 -33
  112. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  113. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  114. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  115. data/lib/ace/assign/authority/router.rb +0 -81
  116. data/lib/ace/assign/authority/server.rb +0 -131
  117. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  118. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  119. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  120. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  121. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  122. data/lib/ace/assign/authority/socket_server.rb +0 -255
  123. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  124. data/lib/ace/assign/authority/transfer_codec.rb +0 -187
  125. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  126. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  127. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  128. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  129. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  130. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  131. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  132. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  133. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  134. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  135. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  136. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  137. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  138. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  139. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  140. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  141. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  142. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  143. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  144. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  145. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  146. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  147. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  148. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  149. data/lib/ace/assign/models/attempt.rb +0 -178
  150. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  151. data/lib/ace/assign/models/evidence_event.rb +0 -86
  152. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  153. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  154. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  155. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  156. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
  157. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  158. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  159. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  160. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
  161. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  162. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  163. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  164. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  165. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  166. data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
  167. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  168. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  169. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  170. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  171. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  172. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  173. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  174. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  175. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
  176. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  177. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
  178. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  179. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  180. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  181. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Assign
5
- module Molecules
6
- # Closed bounded read dialect shared by isolated protected snapshots and
7
- # trusted installed journals. Neither boundary can dispatch a Git write.
8
- module CanonicalReadCommands
9
- BYTE_LIMIT = 256 * 1_048_576
10
- OID = /\A[0-9a-f]{40}\z/
11
-
12
- attr_reader :commit
13
-
14
- # EvidenceJournal uses this only as its existing read command boundary.
15
- def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
16
- refuse!("read_outside_snapshot") unless @view && @commit
17
- unless read_command?(argv, stdin_data)
18
- refuse!("write_or_unsupported_command")
19
- end
20
- result = read_result(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
21
- [result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
22
- end
23
-
24
- def blob_batch(entries, output_limit:)
25
- refuse!("read_outside_snapshot") unless @view && @commit
26
- unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
27
- refuse!("unsupported_command")
28
- end
29
- result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
30
- limit: [output_limit, BYTE_LIMIT].min)
31
- result.stdout.to_s.b
32
- end
33
-
34
- def history_diff(commits, paths, output_limit:)
35
- refuse!("read_outside_snapshot") unless @view && @commit
36
- unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
37
- commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
38
- paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
39
- (paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
40
- output_limit.is_a?(Integer) && output_limit.positive?
41
- refuse!("unsupported_command")
42
- end
43
- result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
44
- --no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
45
- limit: [output_limit, BYTE_LIMIT].min)
46
- result.stdout.to_s.b
47
- end
48
-
49
- def blob_sizes(oids)
50
- refuse!("read_outside_snapshot") unless @view && @commit
51
- unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
52
- refuse!("unsupported_command")
53
- end
54
- run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
55
- end
56
-
57
- def blob(path, commit:, max_bytes:)
58
- refuse!("read_outside_snapshot") unless @view && @commit
59
- unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
60
- refuse!("unsupported_command")
61
- end
62
- result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
63
- result.stdout.to_s.b
64
- end
65
-
66
- private
67
-
68
- def read_result(argv, **options)
69
- run!(argv, **options)
70
- end
71
-
72
- def read_command?(argv, stdin)
73
- return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
74
- allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
75
- "rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
76
- "show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
77
- flags = allowed[argv.first]
78
- flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
79
- end
80
-
81
- end
82
- end
83
- end
84
- end
@@ -1,313 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "fileutils"
5
- require "tmpdir"
6
- require "ace/herdr/molecules/bounded_process"
7
- require_relative "evidence_journal"
8
- require_relative "canonical_read_commands"
9
- require_relative "../authority/private_directory"
10
-
11
- module Ace
12
- module Assign
13
- module Molecules
14
- # Isolates read-only Git from an installed authority's live configuration.
15
- # The copied view never publishes evidence or mutates the source journal.
16
- class CanonicalReadSnapshot
17
- include CanonicalReadCommands
18
- class Unavailable < AttemptErrors::EvidenceUnavailable; end
19
- BYTE_LIMIT = 256 * 1_048_576
20
- ENTRY_LIMIT = 4096
21
- OID = /\A[0-9a-f]{40}\z/
22
- ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
23
- "GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
24
- "GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
25
- "GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
26
- "GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
27
-
28
- # These selections come from installed source composition, not frames.
29
- def initialize(repo_root:, checkout_root:, ref:, private_root:, git_executable: "/usr/bin/git")
30
- paths = [repo_root, checkout_root, private_root, git_executable]
31
- unless paths.all? { |path| path.is_a?(String) && path.start_with?("/") &&
32
- !path.include?("\0") && File.expand_path(path) == path } &&
33
- ref.is_a?(String) && ref.match?(/\Arefs\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)+\z/)
34
- raise ArgumentError, "canonical snapshot source selection is malformed"
35
- end
36
- @repo_root, @checkout_root, @private_root, @git = paths.map { |path| path.dup.freeze }
37
- @ref = ref.dup.freeze
38
- @verification = Mutex.new
39
- end
40
-
41
- def with(deadline:)
42
- refuse!("snapshot_busy") unless @verification.try_lock
43
- begin
44
- with_snapshot(deadline: deadline) { |snapshot| yield snapshot }
45
- ensure
46
- @verification.unlock
47
- end
48
- end
49
-
50
- def with_snapshot(deadline:)
51
- validate_deadline!(deadline)
52
- verify_private_root!
53
- private_stat = directory!(@private_root)
54
- unless private_stat.uid == Process.uid && (private_stat.mode & 0o7777) == 0o700
55
- refuse!("private_storage_unprotected")
56
- end
57
- Dir.mktmpdir("canonical-read-", @private_root) do |view|
58
- verify_private_root!
59
- refuse!("private_storage_unprotected") unless directory_identity(File.lstat(@private_root)) == directory_identity(private_stat)
60
- @view, @deadline = view, deadline
61
- @held, @directories, @inventory, @inventory_files, @metadata_files = [], [], [], [], []
62
- @bytes, @entries = 0, 0
63
- git_dir = File.join(@repo_root, ".git")
64
- directory!(git_dir)
65
- %w[commondir reftable shallow].each do |name|
66
- refuse!("unsupported_source") if exists?(File.join(git_dir, name))
67
- end
68
- lock, original_lock = held_file!(File.join(@checkout_root, ".evidence.lock"), limit: 128)
69
- begin
70
- acquire!(lock)
71
- begin
72
- source_config!(git_dir)
73
- @commit = select_ref!(git_dir)
74
- objects = File.join(git_dir, "objects")
75
- inventory = inventory!(objects)
76
- inventory.each { |relative, handle| copy!(relative, handle) }
77
- verify_held!
78
- refuse!("source_changed") unless inventory_names!(objects) == @inventory &&
79
- select_ref!(git_dir) == @commit && identity(File.lstat(lock.path)) == original_lock
80
- verify_held!
81
- ensure
82
- lock.flock(File::LOCK_UN)
83
- end
84
- end
85
- prepare_view!
86
- run!(%w[fsck --full --strict --no-reflogs], limit: 65_536)
87
- yield self
88
- ensure
89
- @held&.each { |handle| handle.close unless handle.closed? }
90
- @held = @directories = @inventory = @inventory_files = @metadata_files = @view = @deadline = @commit = nil
91
- end
92
- rescue Timeout::Error
93
- refuse!("resource_limit")
94
- rescue IOError, SystemCallError, Herdr::Molecules::BoundedProcess::PostLaunchError
95
- refuse!("source_unavailable")
96
- end
97
- private :with_snapshot
98
-
99
- private
100
-
101
- def refuse!(reason)
102
- raise Unavailable, "canonical read snapshot unavailable: #{reason}"
103
- end
104
-
105
- def validate_deadline!(deadline)
106
- unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
- refuse!("resource_limit")
108
- end
109
- end
110
-
111
- def remaining
112
- value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
- refuse!("resource_limit") unless value.positive?
114
- value
115
- end
116
-
117
- def acquire!(lock)
118
- until lock.flock(File::LOCK_SH | File::LOCK_NB)
119
- sleep [remaining, 0.005].min
120
- end
121
- end
122
-
123
- def identity(stat)
124
- [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.size, stat.mtime, stat.ctime]
125
- end
126
-
127
- def verify_private_root!
128
- Authority::PrivateDirectory.verify!(@private_root)
129
- rescue AttemptErrors::ReceiptRejected
130
- refuse!("private_storage_unprotected")
131
- end
132
-
133
- def directory_identity(stat)
134
- [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid]
135
- end
136
-
137
- def directory!(path)
138
- stat = File.lstat(path)
139
- refuse!("unsupported_source") unless stat.directory? && !stat.symlink?
140
- stat
141
- end
142
-
143
- def exists?(path)
144
- File.lstat(path)
145
- true
146
- rescue Errno::ENOENT
147
- false
148
- end
149
-
150
- def held_file!(path, limit:)
151
- remaining
152
- parent = File.dirname(path)
153
- until parent == "/"
154
- stat = directory!(parent)
155
- @directories << [parent, directory_identity(stat)]
156
- parent = File.dirname(parent)
157
- end
158
- handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
159
- @held << handle
160
- stat = handle.stat
161
- refuse!("unsupported_source") unless stat.file?
162
- refuse!("resource_limit") if stat.size > limit
163
- [handle, identity(stat)]
164
- end
165
-
166
- def select_ref!(git_dir)
167
- loose = File.join(git_dir, @ref)
168
- if exists?(loose)
169
- handle, before = held_file!(loose, limit: 128)
170
- bytes = handle.read(129)
171
- @metadata_files << [loose, handle, before]
172
- refuse!("source_changed") unless identity(handle.stat) == before
173
- refuse!("unsupported_ref") unless bytes.match?(/\A[0-9a-f]{40}\n?\z/)
174
- return bytes.strip
175
- end
176
- packed = File.join(git_dir, "packed-refs")
177
- handle, before = held_file!(packed, limit: 65_536)
178
- bytes = handle.read(65_537)
179
- @metadata_files << [packed, handle, before]
180
- refuse!("source_changed") unless identity(handle.stat) == before
181
- entries = {}
182
- bytes.lines.each do |line|
183
- next if line.start_with?("#") || line.match?(/\A\^[0-9a-f]{40}\n?\z/)
184
- match = /\A([0-9a-f]{40}) (refs\/[^\s\0]+)\n?\z/.match(line)
185
- refuse!("unsupported_ref") unless match && !entries.key?(match[2])
186
- entries[match[2]] = match[1]
187
- end
188
- entries.fetch(@ref) { refuse!("unsupported_ref") }
189
- end
190
-
191
- def source_config!(git_dir)
192
- path = File.join(git_dir, "config")
193
- handle, before = held_file!(path, limit: 65_536)
194
- bytes = handle.read(65_537)
195
- @metadata_files << [path, handle, before]
196
- refuse!("source_changed") unless identity(handle.stat) == before
197
- # Standalone file parsing does not select the target repository,
198
- # follow includes or execute aliases/hooks/helpers. No source config
199
- # is installed into the private administrative view.
200
- result = run!(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
201
- stdin_data: bytes, limit: 65_536)
202
- versions = []
203
- result.stdout.split("\0").each do |entry|
204
- key, value = entry.split("\n", 2)
205
- refuse!("unsupported_source") if key.start_with?("extensions.") || key == "core.worktree"
206
- versions << value if key == "core.repositoryformatversion"
207
- end
208
- refuse!("unsupported_source") unless versions.empty? || versions == ["0"]
209
- end
210
-
211
- def inventory_names!(objects)
212
- names = []
213
- walk = lambda do |path, relative|
214
- remaining
215
- stat = File.lstat(path)
216
- names << [relative, stat.directory? ? "directory" : "file"]
217
- refuse!("resource_limit") if names.size > ENTRY_LIMIT
218
- refuse!("unsupported_source") if stat.symlink? || !(stat.directory? || stat.file?)
219
- if stat.directory?
220
- unless relative.empty? || %w[pack info].include?(relative) || relative.match?(/\A[0-9a-f]{2}\z/)
221
- refuse!("unsupported_source")
222
- end
223
- children = []
224
- Dir.each_child(path) do |name|
225
- remaining
226
- children << name
227
- refuse!("resource_limit") if children.size + names.size > ENTRY_LIMIT
228
- end
229
- children.sort.each { |name| walk.call(File.join(path, name), relative.empty? ? name : "#{relative}/#{name}") }
230
- end
231
- end
232
- walk.call(objects, "")
233
- names
234
- end
235
-
236
- def inventory!(objects)
237
- @inventory = inventory_names!(objects)
238
- files = @inventory.select { |_relative, kind| kind == "file" }.map(&:first)
239
- packs = files.grep(/\Apack\/pack-[0-9a-f]{40}\.pack\z/)
240
- unless packs.all? { |pack| files.include?(pack.sub(/\.pack\z/, ".idx")) } &&
241
- files.grep(/\Apack\/pack-[0-9a-f]{40}\.(?:idx|rev)\z/).all? { |file| packs.include?(file.sub(/\.(?:idx|rev)\z/, ".pack")) }
242
- refuse!("unsupported_source")
243
- end
244
- @inventory.filter_map do |relative, kind|
245
- path = relative.empty? ? objects : File.join(objects, relative)
246
- if kind == "directory"
247
- unless relative.empty? || relative == "pack" || relative == "info" || relative.match?(/\A[0-9a-f]{2}\z/)
248
- refuse!("unsupported_source")
249
- end
250
- @directories << [path, directory_identity(File.lstat(path))]
251
- next
252
- end
253
- # These ordinary Git indexes/retention hints do not redirect
254
- # storage. The clean view never reads them; only object bytes and
255
- # pack indexes are copied. Redirect/lazy-fetch metadata refuses.
256
- next if relative.match?(/\Ainfo\/(?:packs|commit-graph)\z/) ||
257
- relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:bitmap|keep)\z/)
258
- unless relative.match?(/\A[0-9a-f]{2}\/[0-9a-f]{38}\z/) ||
259
- relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:pack|idx|rev)\z/)
260
- refuse!("unsupported_source")
261
- end
262
- handle, before = held_file!(path, limit: BYTE_LIMIT)
263
- @bytes += handle.stat.size
264
- refuse!("resource_limit") if @bytes > BYTE_LIMIT
265
- @inventory_files << [path, handle, before]
266
- [relative, handle]
267
- end
268
- end
269
-
270
- def copy!(relative, source)
271
- destination = File.join(@view, "objects", relative)
272
- FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
273
- File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |target|
274
- copied = 0
275
- expected = source.stat.size
276
- while (chunk = source.read(65_536))
277
- remaining
278
- copied += chunk.bytesize
279
- refuse!("source_changed") if copied > expected
280
- target.write(chunk)
281
- end
282
- refuse!("source_changed") unless copied == expected
283
- end
284
- end
285
-
286
- def verify_held!
287
- @directories.each { |path, before| refuse!("source_changed") unless directory_identity(File.lstat(path)) == before }
288
- (@inventory_files + @metadata_files).each do |path, handle, before|
289
- refuse!("source_changed") unless identity(handle.stat) == before && identity(File.lstat(path)) == before
290
- end
291
- end
292
-
293
- def prepare_view!
294
- FileUtils.mkdir_p(File.join(@view, "objects"), mode: 0o700)
295
- FileUtils.mkdir_p(File.join(@view, "refs"), mode: 0o700)
296
- File.binwrite(File.join(@view, "HEAD"), "#{@commit}\n")
297
- File.binwrite(File.join(@view, "config"), "[core]\nrepositoryformatversion = 0\nbare = true\n")
298
- ref = File.join(@view, @ref)
299
- FileUtils.mkdir_p(File.dirname(ref), mode: 0o700)
300
- File.binwrite(ref, "#{@commit}\n")
301
- end
302
-
303
- def run!(argv, stdin_data: "", limit:)
304
- result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
305
- environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
306
- timeout_s: remaining, output_limit: limit, stderr_limit: 65_536)
307
- refuse!(result.oversized ? "resource_limit" : "git_unavailable") unless result.status.success? && !result.oversized
308
- result
309
- end
310
- end
311
- end
312
- end
313
- end
@@ -1,237 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "open3"
5
- require "ace/support/fs"
6
-
7
- module Ace
8
- module Assign
9
- module Molecules
10
- # Computes delivery evidence for an assignment from accepted,
11
- # current-head execution evidence.
12
- #
13
- # Decisions are derived exclusively from accepted receipts recorded in
14
- # the evidence journal (managed attempts) or the durable attempt store
15
- # (taskless attempts). Report files on disk, exit codes, and prose never
16
- # establish review or merge authorization, and no feedback state is
17
- # hardcoded: an attempt that may have completed without a durable
18
- # receipt yields `uncertain`, never success.
19
- class EvidenceCalculator
20
- def self.calculate(pr_number: nil, auto_merge: false, assignment_id: nil)
21
- new.calculate(pr_number: pr_number, auto_merge: auto_merge, assignment_id: assignment_id)
22
- end
23
-
24
- # @param cache_base [String, nil] Assignment cache base (default config)
25
- # @param repo_root [String, nil] Candidate repository root (default project root)
26
- # @param journal [EvidenceJournal, nil] Evidence journal (default built lazily)
27
- def initialize(cache_base: nil, repo_root: nil, journal: nil)
28
- @cache_base = cache_base
29
- @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
30
- @journal = journal
31
- end
32
-
33
- def calculate(pr_number: nil, auto_merge: false, assignment_id: nil)
34
- head, tree, changed_scope_digest = git_facts
35
- assignment = find_assignment(assignment_id)
36
- attempts = assignment ? attempts_for(assignment) : []
37
- receipts = assignment ? accepted_receipts(assignment) : []
38
-
39
- review_receipt = receipt_currency(receipts, "review", head)
40
- release_receipt = receipt_currency(receipts, "release", head)
41
- unresolved_effects = collect_unresolved_effects(attempts)
42
- if assignment&.managed?
43
- requests = journal.service_requests(assignment.id)
44
- # A rejected effect stays unresolved when its claim had been
45
- # dispatched (authorization consumed): only attributable evidence
46
- # that no effect occurred settles it, never the bare rejection.
47
- unresolved_effects.concat(requests
48
- .select { |request| unresolved_service_state?(request) }
49
- .map { |request| "#{request["request_id"]}:#{request["operation"]}:#{request["state"]}" })
50
- # Settled requests keep their authorization only while their
51
- # receipt evidence remains intact in the candidate repository:
52
- # a deleted or modified artifact drops the outcome back to
53
- # unresolved instead of trusting an unverifiable receipt.
54
- unresolved_effects.concat(requests
55
- .select { |request| request["state"] == "succeeded" || request["state"] == "failed-settled" }
56
- .reject { |request| evidence_intact?(request) }
57
- .map { |request| "#{request["request_id"]}:#{request["operation"]}:evidence-unavailable" })
58
- unresolved_effects.uniq!
59
- end
60
- feedback_state = derive_feedback_state(attempts, receipts, head, unresolved_effects)
61
-
62
- # Authorization requires managed evidence, settled work (no active
63
- # attempts), no unresolved effects, and a current independent
64
- # review verdict. Taskless assignments never authorize merges.
65
- merge_decision = if auto_merge && assignment&.managed? &&
66
- review_receipt == "current" && unresolved_effects.empty? && attempts.none?(&:active?)
67
- "authorized"
68
- else
69
- "approval-required"
70
- end
71
-
72
- latest = attempts.first
73
- active = attempts.find(&:active?)
74
- evidence = {
75
- pr: pr_number || "none",
76
- head: head,
77
- tree: tree,
78
- changed_scope_digest: changed_scope_digest,
79
- review_receipt: review_receipt,
80
- release_receipt: release_receipt,
81
- feedback_state: feedback_state,
82
- merge_decision: merge_decision,
83
- decision_digest: nil,
84
- attempt: latest ? attempt_summary(latest, active: active) : nil,
85
- base_head: latest&.binding&.base_head,
86
- candidate_head: latest&.candidate_head,
87
- evidence_git_ref: evidence_git_ref(assignment),
88
- journal_commit: assignment&.managed? ? journal.ref_value : latest&.journal_commit,
89
- unresolved_effects: unresolved_effects
90
- }
91
- evidence[:decision_digest] = Digest::SHA256.hexdigest(
92
- Atoms::EvidenceDigest.canonical_json(evidence)
93
- )
94
- evidence
95
- end
96
-
97
- private
98
-
99
- # Accepted, uncertain, and failed effects are unresolved by
100
- # definition; a rejected effect stays unresolved when its claim was
101
- # dispatched (authorization consumed), because the effect may have
102
- # happened.
103
- def unresolved_service_state?(request)
104
- %w[accepted uncertain failed].include?(request["state"]) ||
105
- (request["state"] == "rejected" && request["consumed"] != false)
106
- end
107
-
108
- # Every receipt evidence artifact must still exist in the candidate
109
- # repository with its recorded digest; evidence lives in the working
110
- # tree, so its integrity is re-checked on every calculation.
111
- def evidence_intact?(request)
112
- receipt = request["receipt"]
113
- return false unless receipt.is_a?(Hash)
114
- repo_root = File.realpath(@repo_root)
115
- Array(receipt["evidence"]).all? do |item|
116
- path = File.expand_path(item["ref"].to_s, repo_root)
117
- real = begin
118
- File.realpath(path)
119
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
120
- next false
121
- end
122
- begin
123
- real.start_with?(repo_root + File::SEPARATOR) &&
124
- Digest::SHA256.file(real).hexdigest == item["sha256"]
125
- rescue Errno::EACCES, Errno::ELOOP
126
- false
127
- end
128
- end
129
- end
130
-
131
- def git_facts
132
- head, _s = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
133
- tree, _s = Open3.capture2("git", "rev-parse", "HEAD^{tree}", chdir: @repo_root, stdin_data: "")
134
- diff_output, _s = Open3.capture2("git", "diff", "HEAD", chdir: @repo_root, stdin_data: "")
135
-
136
- [head.to_s.strip, tree.to_s.strip, Digest::SHA256.hexdigest(diff_output.to_s)]
137
- end
138
-
139
- def find_assignment(assignment_id)
140
- manager = Molecules::AssignmentManager.new(cache_base: @cache_base)
141
- assignment_id ? manager.load(assignment_id) : manager.find_active
142
- end
143
-
144
- def store
145
- @store ||= Molecules::AssignmentManager.new(cache_base: @cache_base).attempt_store
146
- end
147
-
148
- def journal
149
- @journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
150
- end
151
-
152
- # Attempt state for evidence decisions. Managed assignments treat the
153
- # journal as authoritative: ALL journal-derived attempts (including
154
- # terminal states) replace local records with the same ID, so a crash
155
- # between journaling and the local save cannot leave stale open state.
156
- def attempts_for(assignment)
157
- attempts = store.list(assignment.id)
158
- return attempts unless assignment.managed?
159
-
160
- journal_by_id = {}
161
- journal.derived_attempts(assignment.id).each { |attempt| journal_by_id[attempt.attempt_id] = attempt }
162
- merged = attempts.map { |attempt| journal_by_id.delete(attempt.attempt_id) || attempt }
163
- merged + journal_by_id.values
164
- end
165
-
166
- # Accepted receipts for evidence currency. Managed assignments use
167
- # journal receipts only — the journal is the authority; local records
168
- # are disposable projections.
169
- def accepted_receipts(assignment)
170
- if assignment.managed?
171
- return journal.accepted_receipts(assignment.id).uniq { |receipt| receipt["digest"] }
172
- end
173
-
174
- store.list(assignment.id).flat_map(&:accepted_receipts)
175
- .uniq { |receipt| receipt["digest"] }
176
- end
177
-
178
- # Currency against the current head. Review approval requires an
179
- # executed independent reviewer verdict; receipts were verified at
180
- # acceptance and the independence guard is re-checked here defensively
181
- # (a review receipt without a reviewer verdict is not review evidence
182
- # at all).
183
- def receipt_currency(receipts, operation, head)
184
- succeeded = receipts.select do |receipt|
185
- next false unless receipt["operation"] == operation && receipt["verdict"] == "succeeded"
186
- next false if operation == "review" && receipt.dig("review", "reviewer", "actor").nil?
187
-
188
- true
189
- end
190
-
191
- current = succeeded.any? { |receipt| receipt["head"] == head }
192
-
193
- return "missing" if succeeded.empty?
194
- return "current" if current
195
-
196
- "stale"
197
- end
198
-
199
- def collect_unresolved_effects(attempts)
200
- attempts.flat_map { |attempt| attempt.unresolved_effects.map { |effect| effect["operation"] } }
201
- .concat(attempts.select(&:uncertain?).map { |attempt| "#{attempt.attempt_id}:uncertain" })
202
- .uniq
203
- end
204
-
205
- # Feedback is derived: uncertain evidence wins, active attempts keep
206
- # the loop open, accepted current-head evidence closes it.
207
- def derive_feedback_state(attempts, receipts, head, unresolved_effects)
208
- return "unknown" if attempts.empty? && receipts.empty?
209
-
210
- return "uncertain" if unresolved_effects.any?
211
-
212
- return "terminal" if attempts.none?(&:active?) && receipts.any? do |receipt|
213
- receipt["verdict"] == "succeeded" && receipt["head"] == head
214
- end
215
-
216
- "open"
217
- end
218
-
219
- def attempt_summary(attempt, active: nil)
220
- {
221
- "attempt_id" => attempt.attempt_id,
222
- "state" => attempt.state,
223
- "scope" => attempt.binding.scope,
224
- "recovery_mode" => attempt.recovery_mode,
225
- "active" => active ? active.attempt_id == attempt.attempt_id : false
226
- }
227
- end
228
-
229
- def evidence_git_ref(assignment)
230
- return nil unless assignment&.managed?
231
-
232
- journal.ref
233
- end
234
- end
235
- end
236
- end
237
- end