ace-assign 0.71.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 (195) 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 -2119
  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 -4
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
  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/organisms/assignment_executor.rb +0 -6
  35. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  36. data/lib/ace/assign/version.rb +1 -1
  37. data/lib/ace/assign.rb +0 -6
  38. metadata +4 -176
  39. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  40. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  41. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  42. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  43. data/docs/protected-authority.md +0 -282
  44. data/exe/ace-scope-ready +0 -55
  45. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  46. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  47. data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
  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/atoms/git_blob_batch.rb +0 -59
  52. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  53. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  54. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  55. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  56. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  57. data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
  58. data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
  59. data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
  60. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  61. data/lib/ace/assign/authority/client.rb +0 -257
  62. data/lib/ace/assign/authority/command_execution.rb +0 -180
  63. data/lib/ace/assign/authority/deployment.rb +0 -627
  64. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  65. data/lib/ace/assign/authority/endcap.rb +0 -457
  66. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  67. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  68. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  69. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  70. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  71. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  72. data/lib/ace/assign/authority/endcap_publication.rb +0 -41
  73. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  74. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  75. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  76. data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
  77. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  78. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  79. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  80. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
  81. data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
  82. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  83. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  84. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  85. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  86. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  87. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  88. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  89. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  90. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  91. data/lib/ace/assign/authority/launch_review.rb +0 -91
  92. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  93. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  94. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  95. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  96. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  97. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  98. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  99. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  100. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  101. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  102. data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
  103. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  104. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
  105. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
  106. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  107. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  108. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  109. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
  110. data/lib/ace/assign/authority/ordinary_results.rb +0 -206
  111. data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
  112. data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
  113. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  114. data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
  115. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  116. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  117. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  118. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  119. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  120. data/lib/ace/assign/authority/prepared_work.rb +0 -309
  121. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  122. data/lib/ace/assign/authority/private_directory.rb +0 -33
  123. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  124. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  125. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  126. data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
  127. data/lib/ace/assign/authority/retained_input.rb +0 -53
  128. data/lib/ace/assign/authority/router.rb +0 -81
  129. data/lib/ace/assign/authority/server.rb +0 -131
  130. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  131. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  132. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  133. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  134. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  135. data/lib/ace/assign/authority/socket_server.rb +0 -255
  136. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  137. data/lib/ace/assign/authority/transfer_codec.rb +0 -189
  138. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  139. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  140. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  141. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  142. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  143. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  144. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  145. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  146. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  147. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  148. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  149. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  150. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  151. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  152. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  153. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  154. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  155. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  156. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  157. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  158. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  159. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  160. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  161. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  162. data/lib/ace/assign/models/attempt.rb +0 -178
  163. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  164. data/lib/ace/assign/models/evidence_event.rb +0 -86
  165. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  166. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  167. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  168. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  169. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
  170. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  171. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  172. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  173. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
  174. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  175. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  176. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  177. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  178. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  179. data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
  180. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  181. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  182. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  183. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  184. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  185. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  186. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  187. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  188. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
  189. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  190. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
  191. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  192. data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
  193. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  194. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  195. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,342 +0,0 @@
1
- # frozen_string_literal: true
2
- require "yaml"
3
- require "ace/git/organisms/pull_request_lifecycle"
4
- require_relative "../atoms/delivery_parameters"
5
-
6
- module Ace
7
- module Assign
8
- module Organisms
9
- # Remote delivery is a projection of the existing attempt journal.
10
- # Intents precede writes; an interrupted intent permits reconciliation
11
- # reads only. Privileged merge is executed by qjx, never this worker.
12
- class DeliveryCoordinator
13
- def initialize(repo_root:, coordinator: nil, journal: nil, lifecycle_factory: nil, exclusion: nil, identity_resolver: nil)
14
- @mutex = Mutex.new
15
- @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
16
- @repo_root = repo_root
17
- @journal = journal || Molecules::EvidenceJournal.new(repo_root: repo_root)
18
- @coordinator = coordinator || AttemptCoordinator.new(repo_root: repo_root, journal: @journal)
19
- @factory = lifecycle_factory || ->(selection) { Ace::Git::Organisms::PullRequestLifecycle.new(**selection) }
20
- @exclusion = exclusion || Molecules::LifecycleExclusion.new(repo_root: repo_root)
21
- end
22
-
23
- def perform(**arguments)
24
- @mutex.synchronize { execute_request(**arguments) }
25
- end
26
-
27
- private
28
-
29
- def execute_request(assignment_id:, attempt_id:, operation:, parameters: nil, title: nil, body: nil,
30
- tests: nil, review: nil, service_request_id: nil)
31
- @identity = @identity_resolver.resolve
32
- unless @identity_resolver.trusted?(@identity)
33
- raise AttemptErrors::UnauthorizedIdentity, "Only a trusted coordinator or service may accept delivery evidence"
34
- end
35
- raise ArgumentError, "Unsupported delivery operation" unless %w[create update ready review merge status].include?(operation)
36
- if operation == "create" && (!title.is_a?(String) || title.strip.empty?)
37
- raise ArgumentError, "Create requires a concrete PR title"
38
- end
39
- if operation == "update" && title.nil? && body.nil?
40
- raise ArgumentError, "Update requires title or body"
41
- end
42
- raise ArgumentError, "PR body must be text" unless body.nil? || body.is_a?(String)
43
- params = Atoms::DeliveryParameters.validate(parameters || assignment_parameters(assignment_id))
44
- binding = {"assignment_id" => assignment_id, "attempt_id" => attempt_id}
45
- attempt = @coordinator.authoritative_attempt(binding)
46
- raise AttemptErrors::NotFound, "Delivery attempt not found" unless attempt
47
- raise AttemptErrors::ReceiptRejected, "Delivery requires a managed attempt" unless attempt.managed?
48
- @exclusion.with_shared(@exclusion.task_key(attempt.binding.task_id)) do
49
- @exclusion.with_exclusive(@exclusion.assignment_key(assignment_id)) do
50
- raise AttemptErrors::Conflict, "Assignment was pruned" if @exclusion.removed?(@exclusion.assignment_key(assignment_id))
51
- execute(binding, operation, params, title: title, body: body, tests: tests, review: review,
52
- service_request_id: service_request_id)
53
- end
54
- end
55
- end
56
-
57
- private
58
-
59
- def assignment_parameters(assignment_id)
60
- assignment = Molecules::AssignmentManager.new.load(assignment_id)
61
- raise AttemptErrors::NotFound, "Assignment not found" unless assignment
62
- document = YAML.safe_load_file(assignment.source_config, permitted_classes: [Time, Date])
63
- document.fetch("delivery")
64
- rescue KeyError, Errno::ENOENT, Psych::Exception
65
- raise ArgumentError, "Assignment needs explicit delivery parameters"
66
- end
67
-
68
- def execute(binding, operation, params, **options)
69
- @binding = binding
70
- @intent_context = nil
71
- @attempt = @coordinator.authoritative_attempt(binding)
72
- unless @attempt&.active? && @attempt.managed?
73
- raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active"
74
- end
75
- @head = git!("rev-parse", "HEAD").strip
76
- selection = {server_name: params["forge_server"], use_default: params["forge_default"], repo_root: @repo_root}
77
- historical = events.find { |event| event.dig("payload", "stage") == "identity" }&.fetch("payload")
78
- selection = {server_name: historical.dig("server", "name"), use_default: false, repo_root: @repo_root} if historical
79
- @lifecycle = @factory.call(selection)
80
- server = @lifecycle.resolved_identity
81
- @server = server
82
- Atoms::DeliveryParameters.validate_url!(server.fetch("url"))
83
- provenance = params.fetch("pr_provenance")
84
- unless Ace::Git::Atoms::ServerUrl.match?(server["url"], provenance["base_repository_url"])
85
- raise Ace::Git::ProviderIdentityMismatchError, "Selected forge does not match delivery base"
86
- end
87
- if historical
88
- unless historical["server"] == server && historical["provenance"] == provenance &&
89
- historical["selection"] == params.slice("forge_server", "forge_default")
90
- raise AttemptErrors::Conflict, "Delivery identity or selection changed during attempt"
91
- end
92
- elsif !%w[status review].include?(operation)
93
- record("stage" => "identity", "server" => server, "provenance" => provenance,
94
- "selection" => params.slice("forge_server", "forge_default"))
95
- end
96
- @provenance = provenance
97
- pinned = Ace::Git::ResolvedServer.new(name: server.fetch("name"),
98
- provider: server.fetch("provider").to_sym, url: server.fetch("url"))
99
- @lifecycle = @factory.call(server_name: pinned.name, use_default: false,
100
- repo_root: @repo_root, resolved_server: pinned)
101
- verify_pushed_head!
102
- return status if operation == "status"
103
- return review_snapshot if operation == "review"
104
- pending = unresolved_intent
105
- if pending
106
- result = reconcile(pending)
107
- return result if same_effect?(pending, operation, options)
108
- raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: pending["operation"])
109
- end
110
- require_evidence!(options[:tests], options[:review]) if %w[ready merge].include?(operation)
111
- return consume_merge(options[:service_request_id]) if operation == "merge"
112
- pr = current_pr
113
- if operation == "create" && pr
114
- requested = content_input(options)
115
- metadata = @lifecycle.review_metadata_snapshot(pr.number).pull_request
116
- verify_pr!(metadata)
117
- unless content_matches?(requested.keys.map(&:to_s), Atoms::EvidenceDigest.digest(requested), metadata)
118
- raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: "create")
119
- end
120
- return reconcile({"operation" => "create", "head" => @head,
121
- "fields" => requested.keys.map(&:to_s), "input_digest" => Atoms::EvidenceDigest.digest(requested)})
122
- end
123
- raise AttemptErrors::ReceiptRejected, "Create a draft before #{operation}" if operation != "create" && !pr
124
- verify_pr!(pr) if pr
125
- intent = {"stage" => "intent", "operation" => operation, "head" => @head,
126
- "input_digest" => Atoms::EvidenceDigest.digest(content_input(options)),
127
- "fields" => content_input(options).keys.map(&:to_s),
128
- "pr_number" => pr&.number, "tests" => options[:tests], "review" => options[:review]}
129
- record(intent)
130
- @intent_context = unresolved_intent
131
- receipt = case operation
132
- when "create"
133
- @lifecycle.create(head_ref: provenance["head_ref"], base_ref: provenance["base_ref"],
134
- head_repository_url: provenance["head_repository_url"], expected_head: @head,
135
- title: options[:title], body: options[:body], draft: true)
136
- when "update"
137
- @lifecycle.update(pr.number, expected_head: @head, title: options[:title], body: options[:body])
138
- when "ready" then @lifecycle.ready(pr.number, expected_head: @head)
139
- end
140
- verify_pr!(receipt.pull_request)
141
- unless receipt.operation.to_s == operation && receipt.server_name == server["name"] &&
142
- (operation != "create" || receipt.pull_request.draft == true) &&
143
- (operation != "ready" || receipt.pull_request.draft == false)
144
- raise Ace::Git::ProviderUnknownOutcomeError, "Delivery mutation lacks expected outcome"
145
- end
146
- record_result(operation, receipt.pull_request, "succeeded")
147
- status
148
- rescue Ace::Git::ProviderUnknownOutcomeError
149
- # The durable intent remains unresolved; no exception text or
150
- # provider payload is stored (both may contain sensitive data).
151
- raise
152
- end
153
-
154
- def events
155
- @journal.read_events(@binding.fetch("assignment_id")).select do |event|
156
- event["type"] == "delivery"
157
- end
158
- end
159
-
160
- def record(payload)
161
- producer = {"actor" => @identity.actor, "role" => @identity.role, "runtime" => @identity.runtime}
162
- @journal.record(**@binding.transform_keys(&:to_sym), type: "delivery", payload: payload.merge("producer" => producer),
163
- guard: -> {
164
- live = @coordinator.authoritative_attempt(@binding)
165
- raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active" unless live&.active?
166
- raise AttemptErrors::ReceiptRejected, "Candidate changed during delivery" unless git!("rev-parse", "HEAD").strip == @head
167
- })
168
- end
169
-
170
- def unresolved_intent
171
- history = events
172
- settled = history.filter_map do |event|
173
- payload = event.fetch("payload")
174
- payload["intent_digest"] if payload["stage"] == "result" && payload["outcome"] == "succeeded"
175
- end
176
- pending = history.select do |event|
177
- event.dig("payload", "stage") == "intent" && !settled.include?(event["digest"])
178
- end
179
- raise AttemptErrors::Conflict, "Multiple unresolved delivery intents require reconciliation" if pending.length > 1
180
- event = pending.first
181
- event && event.fetch("payload").merge("intent_digest" => event.fetch("digest"),
182
- "intent_attempt_id" => event.fetch("attempt_id"))
183
- end
184
-
185
- def current_pr
186
- result = events.reverse.find { |event| event.dig("payload", "stage") == "result" && event.dig("payload", "pr") }
187
- result && @lifecycle.show(result.dig("payload", "pr", "number"))
188
- end
189
-
190
- def reconcile(intent)
191
- @intent_context = intent
192
- raise AttemptErrors::ReceiptRejected, "Unresolved delivery belongs to an older candidate" unless intent["head"] == @head
193
- operation = intent.fetch("operation")
194
- if operation == "create"
195
- matches = @lifecycle.reconcile_create(**@provenance.reject { |key, _| key == "mode" }.transform_keys(&:to_sym))
196
- raise Ace::Git::ProviderUnknownOutcomeError, "No exact draft found; create remains unresolved" if matches.empty?
197
- raise Ace::Git::ProviderConflictingMatchesError, "Multiple exact drafts found" unless matches.length == 1
198
- pr = matches.first
199
- verify_pr!(pr)
200
- raise Ace::Git::ProviderUnknownOutcomeError, "Created PR draft state is unproven" unless pr.draft == true
201
- pr = @lifecycle.review_metadata_snapshot(pr.number).pull_request
202
- verify_pr!(pr)
203
- unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
204
- raise Ace::Git::ProviderUnknownOutcomeError, "Created PR content remains unproven"
205
- end
206
- elsif operation == "ready"
207
- require_evidence!(intent["tests"], intent["review"])
208
- pr = @lifecycle.show(intent.fetch("pr_number"))
209
- verify_pr!(pr)
210
- raise Ace::Git::ProviderUnknownOutcomeError, "Readiness remains unresolved" unless pr.draft == false
211
- elsif operation == "update"
212
- pr = @lifecycle.review_metadata_snapshot(intent.fetch("pr_number")).pull_request
213
- verify_pr!(pr)
214
- unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
215
- raise Ace::Git::ProviderUnknownOutcomeError, "PR update remains unresolved"
216
- end
217
- else
218
- raise Ace::Git::ProviderUnknownOutcomeError, "#{operation} requires authoritative receipt reconciliation"
219
- end
220
- record_result(operation, pr, "succeeded")
221
- status
222
- end
223
-
224
- def content_input(options)
225
- options.slice(:title, :body).compact
226
- end
227
-
228
- def content_matches?(fields, digest, pr)
229
- observed = fields.to_h { |field| [field, pr.public_send(field)] }
230
- Atoms::EvidenceDigest.digest(observed) == digest
231
- end
232
-
233
- def same_effect?(intent, operation, options)
234
- return false unless intent["operation"] == operation
235
- case operation
236
- when "create", "update"
237
- intent["input_digest"] == Atoms::EvidenceDigest.digest(content_input(options))
238
- when "ready"
239
- %i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
240
- when "merge"
241
- options[:service_request_id].is_a?(String) &&
242
- options[:service_request_id] == intent["service_request_id"] &&
243
- %i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
244
- else false
245
- end
246
- end
247
-
248
- def require_evidence!(tests, review)
249
- [[tests, "check"], [review, "review-approval"]].each do |ref, kind|
250
- unless ref.is_a?(Hash) && ref.keys.sort == %w[attempt_id receipt_digest]
251
- raise AttemptErrors::ReceiptRejected, "Current accepted #{kind} evidence is required"
252
- end
253
- evidence = @coordinator.evidence(attempt_id: ref["attempt_id"], receipt_digest: ref["receipt_digest"], kind: kind)
254
- unless evidence["head"] == @head && evidence["assignment_id"] == @binding["assignment_id"] &&
255
- evidence["project_id"] == @attempt.binding.project_id
256
- raise AttemptErrors::ReceiptRejected, "Delivery evidence does not match assignment, project and candidate"
257
- end
258
- end
259
- end
260
-
261
- def consume_merge(request_id)
262
- unless request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
263
- raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
264
- end
265
- request = @coordinator.service_request_status(request_id)
266
- unless request && request["state"] == "succeeded" && request["operation"] == "merge" &&
267
- request["assignment_id"] == @binding["assignment_id"] && request["attempt_id"] == @binding["attempt_id"] &&
268
- request["project_id"] == @attempt.binding.project_id && request["candidate_head"] == @head &&
269
- request["authorization"].is_a?(String) && !request["authorization"].empty?
270
- raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
271
- end
272
- @coordinator.validate_service_receipt!(request, "succeeded", request["receipt"])
273
- pr = current_pr
274
- verify_pr!(pr)
275
- unless request.dig("target", "resource") == pr.url && pr.state.to_s == "merged" &&
276
- pr.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
277
- raise AttemptErrors::ReceiptRejected, "Service receipt and merged PR identity do not agree"
278
- end
279
- record_result("merge", pr, "succeeded", "service_request_id" => request_id)
280
- status
281
- end
282
-
283
- def review_snapshot
284
- pr = current_pr
285
- raise AttemptErrors::ReceiptRejected, "Create a draft before remote review" unless pr
286
- verify_pr!(pr)
287
- snapshot = @lifecycle.review_snapshot(pr.number)
288
- verify_pr!(snapshot.pull_request)
289
- snapshot
290
- end
291
-
292
- def verify_pr!(pr)
293
- reference = pr && Ace::Git::Atoms::PrReference.parse(pr.url)
294
- unless reference && reference.number == pr.number && reference.repository_url &&
295
- Ace::Git::Atoms::ServerUrl.match?(reference.repository_url, @provenance["base_repository_url"])
296
- raise Ace::Git::ProviderIdentityMismatchError, "PR URL does not match delivery repository and number"
297
- end
298
- Atoms::DeliveryParameters.validate_url!(pr.url)
299
- unless pr && pr.server_name == @server["name"] && pr.number.is_a?(Integer) && pr.number.positive? &&
300
- pr.head_sha == @head && pr.head_ref == @provenance["head_ref"] &&
301
- pr.base_ref == @provenance["base_ref"] &&
302
- Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, @provenance["head_repository_url"]) &&
303
- Ace::Git::Atoms::ServerUrl.match?(pr.base_repository_url, @provenance["base_repository_url"])
304
- raise Ace::Git::ProviderExpectedHeadConflictError, "PR candidate or provenance does not match delivery"
305
- end
306
- end
307
-
308
- def verify_pushed_head!
309
- output = git!("ls-remote", "--heads", @provenance["head_repository_url"], "refs/heads/#{@provenance['head_ref']}")
310
- expected = "#{@head}\trefs/heads/#{@provenance['head_ref']}"
311
- raise AttemptErrors::ReceiptRejected, "Pushed source does not match candidate" unless output.strip == expected
312
- end
313
-
314
- def record_result(operation, pr, outcome, extra = {})
315
- record({"stage" => "result", "operation" => operation, "head" => @head, "outcome" => outcome,
316
- "intent_digest" => @intent_context&.fetch("intent_digest", nil),
317
- "intent_attempt_id" => @intent_context&.fetch("intent_attempt_id", nil),
318
- "pr" => {"number" => pr.number, "url" => pr.url, "head_sha" => pr.head_sha,
319
- "draft" => pr.draft, "state" => pr.state.to_s, "merge_commit_sha" => pr.merge_commit_sha}}.merge(extra))
320
- end
321
-
322
- def status
323
- attempt = @coordinator.authoritative_attempt(@binding)
324
- {"attempt_id" => attempt.attempt_id, "assignment_id" => @binding["assignment_id"],
325
- "task_id" => attempt.binding.task_id, "project_id" => attempt.binding.project_id,
326
- "actor" => attempt.binding.actor, "role" => attempt.binding.role, "runtime" => attempt.binding.runtime,
327
- "base_head" => attempt.binding.base_head, "candidate_head" => attempt.candidate_head,
328
- "evidence_git_ref" => @attempt.binding.evidence_git_ref, "journal_commit" => @journal.ref_value,
329
- "delivery" => events.map do |event|
330
- event.fetch("payload").merge("attempt_id" => event.fetch("attempt_id"), "event_digest" => event.fetch("digest"))
331
- end}
332
- end
333
-
334
- def git!(*argv)
335
- result = Ace::Git::Atoms::CommandExecutor.execute("git", "-C", @repo_root, *argv)
336
- raise AttemptErrors::EvidenceUnavailable, "Git delivery proof unavailable" unless result[:success]
337
- result[:output]
338
- end
339
- end
340
- end
341
- end
342
- end
@@ -1,310 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "tmpdir"
5
- require "fileutils"
6
- require_relative "../authority/retained_input"
7
- require_relative "../authority/prepared_work"
8
- require_relative "../authority/transfer_codec"
9
- require_relative "../molecules/journal_mutation"
10
- require_relative "../models/execution_receipt"
11
- require_relative "../models/assignment"
12
- require_relative "../molecules/queue_scanner"
13
-
14
- module Ace
15
- module Assign
16
- module Organisms
17
- # Operator protocol over the installed owner supplied by the composition.
18
- # Files retain requests only; every observation comes from that owner.
19
- class OrdinaryDriver
20
- Files = Authority::RetainedInput
21
- CONTEXT_KEYS = %w[version project_id assignment_id attempt_id scope pane supervisor_pane cwd head provider].freeze
22
- attr_reader :context, :path
23
-
24
- def initialize(path:, project:, transport:)
25
- @path = File.expand_path(path)
26
- @context = Files.json(@path)
27
- unless @context.is_a?(Hash) && @context.keys.sort == CONTEXT_KEYS.sort && @context["version"] == 1 &&
28
- @context["project_id"] == project && %w[project_id assignment_id attempt_id].all? { |key|
29
- @context[key].is_a?(String) && Molecules::JournalMutation::ID.match?(@context[key]) } &&
30
- @context["scope"].is_a?(String) && Authority::PreparedWork::SCOPE.match?(@context["scope"]) &&
31
- %w[pane supervisor_pane].all? { |key| @context[key].is_a?(String) && @context[key].match?(/\A[\w.:-]{1,128}\z/) } &&
32
- @context["cwd"].is_a?(String) && @context["cwd"].start_with?("/") &&
33
- File.expand_path(@context["cwd"]) == @context["cwd"] && @context["head"].is_a?(String) &&
34
- @context["head"].match?(/\A[0-9a-f]{40}\z/) && @context["provider"].is_a?(String) &&
35
- @context["provider"].bytesize.between?(1, 512)
36
- raise ArgumentError, "ordinary driver selection differs; retain the original context"
37
- end
38
- @root = Authority::PrivateDirectory.verify!(File.dirname(@path))
39
- @transport = transport
40
- end
41
-
42
- def status
43
- reply = read("result_status", selection)
44
- data = reply.fetch("data")
45
- unless data.values_at("project_id", "assignment_id", "attempt_id") == context.values_at("project_id", "assignment_id", "attempt_id")
46
- raise AttemptErrors::EvidenceUnavailable, "canonical status belongs to another execution"
47
- end
48
- data
49
- end
50
-
51
- def start(pane:)
52
- worker!(pane)
53
- mutate("start", "start_attempt", selection.merge("expected_generation" => 0,
54
- "pane" => pane, "scope" => context.fetch("scope"), "head" => context.fetch("head")))
55
- end
56
-
57
- def supervisor
58
- data = read("supervisor_status", context.slice("assignment_id")).fetch("data")
59
- unless data.values_at("project_id", "assignment_id") == context.values_at("project_id", "assignment_id")
60
- raise AttemptErrors::EvidenceUnavailable, "canonical supervisor belongs to another assignment"
61
- end
62
- data
63
- end
64
-
65
- def adopt_supervisor(pane:, generation:, previous_binding_sha256:)
66
- mutate("supervisor-#{generation}", "adopt_supervisor", context.slice("assignment_id").merge(
67
- "pane" => pane, "expected_generation" => generation, "previous_binding_sha256" => previous_binding_sha256))
68
- end
69
-
70
- def queue
71
- reply, bytes = snapshot("assignment_snapshot", selection.merge("kind" => "queue"))
72
- data = reply.fetch("data")
73
- unless data.values_at("project_id", "assignment_id", "attempt_id", "scope") ==
74
- context.values_at("project_id", "assignment_id", "attempt_id", "scope") &&
75
- Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
76
- raise AttemptErrors::EvidenceUnavailable, "canonical queue belongs to another execution"
77
- end
78
- value = JSON.parse(bytes)
79
- assignment = Models::Assignment.new(id: context.fetch("assignment_id"), name: "ordinary",
80
- created_at: Time.now.utc, source_config: "canonical", cache_dir: @root)
81
- state = Molecules::QueueScanner.new.scan_captured(steps: value.fetch("steps"),
82
- reports: value.fetch("reports"), assignment: assignment)
83
- scope = context.fetch("scope")
84
- current = state.current_in_subtree(scope)
85
- next_step = state.next_workable_in_subtree(scope)
86
- data.merge("complete" => state.subtree_complete?(scope), "failed" => state.subtree_failed?(scope),
87
- "current" => step(current), "next" => step(next_step), "reports" => value.fetch("reports"))
88
- end
89
-
90
- def bootstrap(output:)
91
- # Accepted task/context bytes are recovered from the canonical owner,
92
- # not the worker checkout's possibly older mutable task files.
93
- reply, bytes = snapshot("assignment_snapshot", selection.merge("attempt_id" => nil, "kind" => "prepared_work"))
94
- data = reply.fetch("data")
95
- unless data.values_at("project_id", "assignment_id") == context.values_at("project_id", "assignment_id")
96
- raise AttemptErrors::EvidenceUnavailable, "canonical preparation belongs to another assignment"
97
- end
98
- reference = data.fetch("prepared_work")
99
- work = Authority::PreparedWork.admit(bytes: bytes, sha256: data.fetch("sha256"), size: data.fetch("bytes"),
100
- head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"), root: @root,
101
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
102
- unless work.reference(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree")) == reference &&
103
- work.manifest.values_at("project_id", "assignment_id", "scope") == context.values_at("project_id", "assignment_id", "scope")
104
- raise AttemptErrors::EvidenceUnavailable, "canonical prepared scope differs"
105
- end
106
- directory = Files.create_output(output)
107
- inventory = work.manifest.fetch("context").each_with_index.flat_map do |entry, index|
108
- selected = %w[spec text].map { |kind| [kind, entry.fetch(kind)] } +
109
- entry.fetch("reports").each_with_index.map { |report, number| ["report-#{number}", report.fetch("file")] }
110
- selected.map do |kind, source|
111
- content = work.files.fetch(source.fetch("filename"))
112
- filename = "context-#{index}-#{kind}.md"
113
- Files.write_file(directory, filename, content)
114
- {"task_id" => entry.fetch("task_id"), "kind" => kind, "path" => File.join(directory, filename),
115
- "sha256" => Digest::SHA256.hexdigest(content), "bytes" => content.bytesize}
116
- end
117
- end
118
- {"project_id" => context.fetch("project_id"), **selection, "scope" => context.fetch("scope"),
119
- "manifest_sha256" => work.manifest_sha256, "files" => inventory, "journal_commit" => data.fetch("journal_commit")}
120
- end
121
-
122
- def progress(operation:, pane:, generation:, step: nil, report: nil, message: nil)
123
- worker!(pane)
124
- params = selection.merge("expected_generation" => generation, "pane" => pane)
125
- parts = []
126
- case operation
127
- when "queue_start" then params["step"] = step
128
- when "queue_finish"
129
- params["step"] = step
130
- parts << Files.read_file(report, 65_536)
131
- when "queue_fail" then params["message"] = message
132
- else raise ArgumentError, "unknown ordinary queue operation"
133
- end
134
- mutate("progress-#{generation}", operation, params, parts)
135
- end
136
-
137
- def submit(pane:, generation:, receipt:, artifacts: [])
138
- worker!(pane)
139
- bytes = Files.read_file(receipt, 16_384)
140
- value = JSON.parse(bytes)
141
- unless value.values_at("project_id", "assignment_id", "attempt_id", "scope") ==
142
- context.values_at("project_id", "assignment_id", "attempt_id", "scope") &&
143
- value.fetch("artifacts").size == artifacts.size
144
- raise ArgumentError, "result receipt differs from ordinary selection"
145
- end
146
- parts = artifacts.each_with_index.map do |file, index|
147
- content = Files.read_file(file, Authority::TransferCodec::LIMITS.fetch(:receipt_artifacts).last)
148
- unless Digest::SHA256.hexdigest(content) == value.fetch("artifacts").fetch(index).fetch("sha256")
149
- raise ArgumentError, "result artifact differs from its receipt"
150
- end
151
- content
152
- end
153
- params = selection.merge("expected_generation" => generation,
154
- "pane" => pane, "head" => value.fetch("head"), "receipt_sha256" => Digest::SHA256.hexdigest(bytes))
155
- verify_result_intent!(params, [bytes] + parts)
156
- observed = status
157
- if (accepted = observed.fetch("submitted_result"))
158
- # An accepted result may have lost its reply. Its generation selects
159
- # the original input; never mint a replacement mutation to recover it.
160
- original_generation = accepted.fetch("generation") - 1
161
- input = File.join(@root, "requests", "result-#{original_generation}", "request.json")
162
- original = Files.json(input)
163
- expected = document("submit_result", params.merge("expected_generation" => original_generation), nil,
164
- inventory([bytes] + parts))
165
- unless original.except("mutation_id") == expected.except("mutation_id")
166
- raise ArgumentError, "accepted result differs from retained original input"
167
- end
168
- return replay(input: input).merge("request" => input)
169
- end
170
- unless observed.fetch("state") == "running" && observed.fetch("generation") == generation
171
- raise AttemptErrors::EvidenceUnavailable, "result requires the current canonical generation; retain original inputs"
172
- end
173
- reply = mutate("result-#{generation}", "submit_result", params, [bytes] + parts)
174
- signal_result(reply.fetch("data"))
175
- reply
176
- end
177
-
178
- def adopt(pane:)
179
- owner = supervisor
180
- raise ArgumentError, "ordinary result owner pane differs" unless pane == owner.fetch("binding").fetch("pane")
181
- observed = status
182
- return {"status" => "ok", "data" => observed} if %w[succeeded failed].include?(observed.fetch("state"))
183
- result = observed.fetch("submitted_result")
184
- raise AttemptErrors::EvidenceUnavailable, "ordinary worker has not submitted a result" unless result
185
- mutate("adopt-#{owner.fetch('generation')}-#{observed.fetch('generation')}", "finish", selection.merge("pane" => pane,
186
- "expected_generation" => observed.fetch("generation"),
187
- **result.slice("result_id", "receipt_digest", "head")))
188
- end
189
-
190
- def replay(input:)
191
- request = Files.json(input)
192
- expected = request["operation"] == "adopt_supervisor" ? context.slice("assignment_id") : selection
193
- unless request["project_id"] == context.fetch("project_id") &&
194
- request.fetch("params").slice("assignment_id", "attempt_id") == expected &&
195
- File.dirname(File.dirname(File.expand_path(input))) == File.join(@root, "requests")
196
- raise ArgumentError, "recovery request differs from original execution"
197
- end
198
- reply = @transport.request(input: input)
199
- signal_result(reply.fetch("data")) if request.fetch("operation") == "submit_result"
200
- reply
201
- end
202
-
203
- private
204
-
205
- def selection = context.slice("assignment_id", "attempt_id")
206
-
207
- # A filesystem watch hint is neither execution state nor a receipt.
208
- # Adoption never reads this file, even after restart or forged writes.
209
- def signal_result(result)
210
- name = "hint-#{SecureRandom.hex(8)}"
211
- Files.write_file(@root, name, JSON.generate({"hint_only" => true,
212
- "project_id" => context.fetch("project_id"), **selection,
213
- "result_id" => result.fetch("result_id")}))
214
- File.rename(File.join(@root, name), File.join(@root, "result-hint.json"))
215
- File.open(@root) { |file| file.fsync }
216
- ensure
217
- File.unlink(File.join(@root, name)) if name && File.exist?(File.join(@root, name))
218
- end
219
-
220
- def worker!(pane)
221
- raise ArgumentError, "ordinary worker pane differs" unless pane == context.fetch("pane")
222
- end
223
-
224
- def step(value)
225
- value && {"number" => value.number, "name" => value.name, "status" => value.status.to_s,
226
- "instructions" => value.instructions, "workflow" => value.workflow, "skill" => value.skill}
227
- end
228
-
229
- def read(operation, params)
230
- Dir.mktmpdir("ordinary-read-", @root) do |directory|
231
- Files.write_file(directory, "request.json", JSON.generate(document(operation, params, nil, [])))
232
- @transport.request(input: File.join(directory, "request.json"))
233
- end
234
- end
235
-
236
- def snapshot(operation, params)
237
- Dir.mktmpdir("ordinary-read-", @root) do |directory|
238
- Files.write_file(directory, "request.json", JSON.generate(document(operation, params, nil, [])))
239
- reply = @transport.request(input: File.join(directory, "request.json"), output: File.join(directory, "snapshot"))
240
- entries = reply.fetch("files")
241
- raise AttemptErrors::EvidenceUnavailable, "canonical queue snapshot count differs" unless entries.one?
242
- [reply, Files.read_file(entries.first.fetch("file"), Authority::PreparedWork::MAX_TOTAL)]
243
- end
244
- end
245
-
246
- def mutate(key, operation, params, parts = [])
247
- request = retain(key, operation, params, parts)
248
- @transport.request(input: request).merge("request" => request)
249
- rescue StandardError => error
250
- raise error.class, "#{error.message}; recover original request #{request || key} before new work"
251
- end
252
-
253
- def retain(key, operation, params, parts)
254
- directory = File.join(@root, "requests")
255
- begin
256
- Dir.mkdir(directory, 0o700)
257
- rescue Errno::EEXIST
258
- Authority::PrivateDirectory.verify!(directory)
259
- end
260
- raise ArgumentError, "ordinary retained operation key is invalid" unless key.match?(/\A[a-z0-9-]{1,128}\z/)
261
- File.open(File.join(directory, ".lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
262
- lock.flock(File::LOCK_EX)
263
- target = File.join(directory, key)
264
- value = document(operation, params, nil, inventory(parts))
265
- if File.exist?(target)
266
- Authority::PrivateDirectory.verify!(target)
267
- original = Files.json(File.join(target, "request.json"))
268
- unless original.except("mutation_id") == value.except("mutation_id")
269
- raise ArgumentError, "retained operation changed; replay its exact request or reconcile canonical state"
270
- end
271
- else
272
- scratch = Dir.mktmpdir(".prepare-", directory)
273
- begin
274
- parts.each_with_index { |bytes, index| Files.write_file(scratch, "part-#{index}", bytes) }
275
- value["mutation_id"] = SecureRandom.hex(16)
276
- Files.write_file(scratch, "request.json", JSON.generate(value) + "\n")
277
- File.rename(scratch, target)
278
- File.open(directory) { |handle| handle.fsync }
279
- ensure
280
- FileUtils.remove_entry(scratch) if File.exist?(scratch)
281
- end
282
- end
283
- File.join(target, "request.json")
284
- end
285
- end
286
-
287
- def document(operation, params, mutation, parts)
288
- {"version" => 1, "project_id" => context.fetch("project_id"), "operation" => operation,
289
- "mutation_id" => mutation, "params" => params, "parts" => parts}
290
- end
291
-
292
- def inventory(parts)
293
- parts.each_with_index.map { |bytes, index|
294
- {"file" => "part-#{index}", "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)} }
295
- end
296
-
297
- def verify_result_intent!(params, parts)
298
- expected = document("submit_result", params.except("expected_generation"), nil, inventory(parts))
299
- Dir.glob(File.join(@root, "requests", "result-*", "request.json")).each do |input|
300
- original = Files.json(input)
301
- original = original.merge("params" => original.fetch("params").except("expected_generation"))
302
- unless original.except("mutation_id") == expected.except("mutation_id")
303
- raise ArgumentError, "result retry changed the retained original intent"
304
- end
305
- end
306
- end
307
- end
308
- end
309
- end
310
- end