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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +0 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +0 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +0 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +0 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +0 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +0 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +0 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +0 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +0 -0
  11. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +0 -0
  12. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +0 -0
  13. data/.ace-defaults/assign/catalog/steps/lint.step.yml +0 -0
  14. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +0 -0
  15. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +0 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -0
  17. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -0
  18. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +0 -0
  19. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +0 -0
  20. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +0 -0
  21. data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +0 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +0 -0
  23. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +0 -0
  24. data/.ace-defaults/assign/catalog/steps/research.step.yml +0 -0
  25. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -0
  26. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +0 -0
  27. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +0 -0
  28. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +0 -0
  29. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +0 -0
  30. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +0 -0
  31. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +0 -0
  32. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +0 -0
  33. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -0
  34. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  35. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  36. data/.ace-defaults/assign/presets/quick-implement.yml +0 -0
  37. data/.ace-defaults/assign/presets/release-only.yml +0 -0
  38. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  39. data/.ace-defaults/assign/presets/work-on-docs.yml +0 -0
  40. data/.ace-defaults/assign/presets/work-on-task-in-place.yml +0 -0
  41. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +0 -0
  42. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +0 -0
  43. data/CHANGELOG.md +17 -0
  44. data/Rakefile +0 -0
  45. data/docs/demo/canonical-skill-source.gif +0 -0
  46. data/docs/demo/fork-provider.cast +0 -0
  47. data/docs/demo/fork-provider.gif +0 -0
  48. data/docs/demo/fork-provider.recording.json +0 -0
  49. data/docs/demo/fork-provider.tape.yml +0 -0
  50. data/docs/getting-started.md +0 -0
  51. data/docs/handbook.md +0 -0
  52. data/handbook/guides/fork-context.g.md +0 -0
  53. data/handbook/skills/as-assign-add-task/SKILL.md +0 -0
  54. data/handbook/skills/as-assign-compose/SKILL.md +0 -0
  55. data/handbook/skills/as-assign-create/SKILL.md +0 -0
  56. data/handbook/skills/as-assign-drive/SKILL.md +0 -0
  57. data/handbook/skills/as-assign-prepare/SKILL.md +0 -0
  58. data/handbook/skills/as-assign-recover-fork/SKILL.md +0 -0
  59. data/handbook/skills/as-assign-run-in-batches/SKILL.md +0 -0
  60. data/handbook/skills/as-assign-start/SKILL.md +0 -0
  61. data/handbook/skills/as-create-retro-internal/SKILL.md +0 -0
  62. data/handbook/skills/as-mark-task-done-internal/SKILL.md +0 -0
  63. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +0 -0
  64. data/handbook/skills/as-task-load-internal/SKILL.md +0 -0
  65. data/handbook/workflow-instructions/assign/add-task.wf.md +0 -0
  66. data/handbook/workflow-instructions/assign/compose.wf.md +0 -0
  67. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +0 -0
  68. data/handbook/workflow-instructions/assign/create.wf.md +0 -0
  69. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +0 -0
  70. data/handbook/workflow-instructions/assign/recover-fork.wf.md +0 -0
  71. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +0 -0
  72. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +0 -0
  73. data/handbook/workflow-instructions/assign/start.wf.md +0 -0
  74. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +0 -0
  75. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +0 -0
  76. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +0 -0
  77. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  78. data/lib/ace/assign/atoms/catalog_loader.rb +0 -0
  79. data/lib/ace/assign/atoms/composition_rules.rb +0 -0
  80. data/lib/ace/assign/atoms/number_generator.rb +0 -0
  81. data/lib/ace/assign/atoms/preset_expander.rb +0 -0
  82. data/lib/ace/assign/atoms/preset_loader.rb +0 -0
  83. data/lib/ace/assign/atoms/preset_step_resolver.rb +0 -0
  84. data/lib/ace/assign/atoms/step_file_parser.rb +0 -0
  85. data/lib/ace/assign/atoms/step_numbering.rb +0 -0
  86. data/lib/ace/assign/atoms/step_sorter.rb +0 -0
  87. data/lib/ace/assign/atoms/tree_formatter.rb +0 -0
  88. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  89. data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
  90. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  91. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  92. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  93. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  94. data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
  95. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  96. data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
  97. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  98. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  99. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  100. data/lib/ace/assign/authority/ordinary_results.rb +196 -0
  101. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  102. data/lib/ace/assign/authority/prepared_work.rb +2 -2
  103. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  104. data/lib/ace/assign/authority/router.rb +11 -6
  105. data/lib/ace/assign/authority/server.rb +11 -180
  106. data/lib/ace/assign/authority/socket_server.rb +255 -0
  107. data/lib/ace/assign/authority/transfer_codec.rb +2 -2
  108. data/lib/ace/assign/cli/commands/add.rb +0 -0
  109. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  110. data/lib/ace/assign/cli/commands/list.rb +0 -0
  111. data/lib/ace/assign/cli/commands/retry_cmd.rb +0 -0
  112. data/lib/ace/assign/models/assignment_info.rb +0 -0
  113. data/lib/ace/assign/models/queue_state.rb +0 -0
  114. data/lib/ace/assign/models/step.rb +0 -0
  115. data/lib/ace/assign/molecules/assignment_discoverer.rb +0 -0
  116. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  117. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  118. data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
  119. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  120. data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
  121. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  122. data/lib/ace/assign/molecules/preset_inferrer.rb +0 -0
  123. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  124. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +0 -0
  125. data/lib/ace/assign/molecules/step_renumberer.rb +0 -0
  126. data/lib/ace/assign/organisms/attempt_coordinator.rb +58 -36
  127. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  128. data/lib/ace/assign/version.rb +1 -1
  129. metadata +24 -10
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Source-owned progress for the already accepted work. Queue completion
7
+ # remains a worker report, never a successful execution result or merge.
8
+ module OrdinaryProgress
9
+ PROGRESS_OPERATIONS = %w[queue_start queue_finish queue_fail assignment_snapshot].freeze
10
+ PROGRESS_TRANSFERS = {
11
+ "queue_finish" => {direction: :upload, purpose: :artifacts, roles: %i[worker supervisor]},
12
+ "assignment_snapshot" => {direction: :download, purpose: :assignment_snapshot, roles: %i[worker supervisor reviewer]}
13
+ }.freeze
14
+ PROGRESS_COMMON = %w[assignment_id attempt_id expected_generation pane].freeze
15
+
16
+ private
17
+
18
+ def validate_progress!(request)
19
+ params = request.fetch("params")
20
+ operation = request.fetch("operation")
21
+ if operation == "assignment_snapshot"
22
+ strict!(params, %w[assignment_id attempt_id kind])
23
+ token!(params.fetch("attempt_id")) unless params.fetch("attempt_id").nil?
24
+ unless request.fetch("mutation_id").nil? && %w[prepared_work queue].include?(params.fetch("kind"))
25
+ raise ArgumentError, "ordinary snapshot must be a read of accepted work or queue"
26
+ end
27
+ else
28
+ fields = case operation
29
+ when "queue_start" then %w[step]
30
+ when "queue_finish" then %w[step transfer]
31
+ when "queue_fail" then %w[message]
32
+ else raise ArgumentError, "unknown ordinary progress operation"
33
+ end
34
+ strict!(params, PROGRESS_COMMON + fields)
35
+ token!(params.fetch("attempt_id")); token!(request.fetch("mutation_id"))
36
+ unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive?
37
+ raise ArgumentError, "ordinary progress generation is invalid"
38
+ end
39
+ if params.key?("step") && !params["step"].nil? &&
40
+ !(params["step"].is_a?(String) && params["step"].bytesize.between?(1, 128) && PreparedWork::SCOPE.match?(params["step"]))
41
+ raise ArgumentError, "ordinary progress step is invalid"
42
+ end
43
+ if params.key?("message") && !(params["message"].is_a?(String) && params["message"].bytesize.between?(1, 1024) && !params["message"].include?("\0"))
44
+ raise ArgumentError, "ordinary failure message is invalid"
45
+ end
46
+ end
47
+ token!(params.fetch("assignment_id"))
48
+ end
49
+
50
+ def assignment_snapshot(request, peer, role, deadline)
51
+ validate_progress!(request)
52
+ params = request.fetch("params")
53
+ commit = @journal.ref_value
54
+ registered = registration(params.fetch("assignment_id"), commit: commit)
55
+ raise AttemptErrors::NotFound, "ordinary assignment is not registered" unless registered
56
+ queue_record = current_queue_record(registered, commit)
57
+ selected_events = if params.fetch("attempt_id")
58
+ result_events(params, commit)
59
+ else
60
+ []
61
+ end
62
+ kind = params.fetch("kind")
63
+ source = kind == "prepared_work" ? registered : queue_record
64
+ path_key, digest_key, size = if kind == "prepared_work"
65
+ ["prepared_bundle_ref", "prepared_bundle_sha256", registered.fetch("prepared_bundle_bytes")]
66
+ else
67
+ ["queue_ref", "queue_sha256", PreparedWork::MAX_TOTAL]
68
+ end
69
+ bytes = @journal.bounded_blob(source.fetch(path_key), commit: commit, max_bytes: size)
70
+ unless Digest::SHA256.hexdigest(bytes) == source.fetch(digest_key)
71
+ raise AttemptErrors::EvidenceUnavailable, "ordinary snapshot bytes differ from accepted selection"
72
+ end
73
+ authorize!(request, peer, role, deadline)
74
+ {data: {"project_id" => @project, "assignment_id" => params.fetch("assignment_id"), "kind" => kind,
75
+ "attempt_id" => params.fetch("attempt_id"), "generation" => @journal.authority_generation(selected_events),
76
+ "scope" => selected_events.find { |event| event["type"] == "intent" }&.dig("payload", "scope"),
77
+ "journal_commit" => commit, "sha256" => source.fetch(digest_key), "bytes" => bytes.bytesize,
78
+ "prepared_work" => registered.fetch("prepared_work"), "definition_digest" => registered.fetch("definition_digest")},
79
+ transfer_parts: [bytes], replayed: false}
80
+ end
81
+
82
+ def progress(request, peer, role, deadline, transfer)
83
+ validate_progress!(request)
84
+ unless %i[supervisor worker].include?(role)
85
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary progress requires its original worker"
86
+ end
87
+ params, operation = request.fetch("params"), request.fetch("operation")
88
+ report = if operation == "queue_finish"
89
+ raise ArgumentError, "ordinary progress report needs one bounded part" unless transfer && transfer.count == 1
90
+ bytes = transfer.bytes.dup.force_encoding(Encoding::UTF_8)
91
+ unless bytes.bytesize.between?(1, 65_536) && bytes.valid_encoding? && !bytes.include?("\0")
92
+ raise ArgumentError, "ordinary progress report must be bounded UTF-8"
93
+ end
94
+ bytes
95
+ elsif transfer
96
+ raise ArgumentError, "ordinary progress operation forbids transfer"
97
+ end
98
+ commit = @journal.ref_value
99
+ registered = registration(params.fetch("assignment_id"), commit: commit)
100
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
101
+ work = registered_work(registered, commit, deadline)
102
+ digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
103
+ keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
104
+ @exclusion.with_shared_multi(keys, deadline: deadline) do
105
+ authorize!(request, peer, role, deadline)
106
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
107
+ mutation_id: request.fetch("mutation_id"), operation: operation, parameters_digest: digest,
108
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
109
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, _generation|
110
+ authorize_start_replay!(request, peer, role, deadline)
111
+ unless Molecules::CanonicalAttemptState.derive(events) == "running"
112
+ raise AttemptErrors::Conflict, "ordinary progress requires the original running attempt"
113
+ end
114
+ record = current_queue_record(registered, prefix)
115
+ scope = events.find { |event| event["type"] == "intent" }.fetch("payload").fetch("scope")
116
+ queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(record, prefix))
117
+ next_queue = case operation
118
+ when "queue_start" then queue.transition(operation: :start, scope: scope, step: params.fetch("step"))
119
+ when "queue_finish" then queue.transition(operation: :finish, scope: scope, step: params.fetch("step"), report: report)
120
+ when "queue_fail" then queue.transition(operation: :fail, scope: scope, message: params.fetch("message"))
121
+ end
122
+ sha = Digest::SHA256.hexdigest(next_queue.bytes)
123
+ path = "execution/definitions/#{params.fetch('assignment_id')}-queue-#{sha}.json"
124
+ {events: [], blobs: {path => next_queue.bytes}, data: {
125
+ "authority" => "ordinary", "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
126
+ "project_id" => @project, "scope" => scope, "queue_ref" => path, "queue_sha256" => sha, "state" => "running",
127
+ "queue_complete" => next_queue.status.subtree_complete?(scope)}}
128
+ end
129
+ end
130
+ end
131
+
132
+ def current_queue_record(registered, commit)
133
+ assignment = registered.fetch("assignment_id")
134
+ events = @journal.read_events(assignment, commit: commit)
135
+ events.group_by { |event| event.fetch("attempt_id") }.each_value do |chain|
136
+ raise AttemptErrors::EvidenceUnavailable, "ordinary queue history is corrupt" unless Models::EvidenceEvent.chain_valid?(chain)
137
+ end
138
+ updates = events.select do |entry|
139
+ entry["type"] == "authority_mutation" &&
140
+ %w[start_attempt queue_start queue_finish queue_fail].include?(entry.dig("payload", "operation"))
141
+ end
142
+ return registered if updates.empty?
143
+ event = @journal.latest_event!(assignment_id: assignment, event_digests: updates.map { |entry| entry.fetch("digest") }, commit: commit)
144
+ record = event.dig("payload", "data")
145
+ unless record && record.values_at("authority", "assignment_id", "attempt_id", "project_id") ==
146
+ ["ordinary", assignment, event.fetch("attempt_id"), @project]
147
+ raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue is unavailable"
148
+ end
149
+ record
150
+ end
151
+
152
+ def queue_bytes(record, commit)
153
+ bytes = @journal.bounded_blob(record.fetch("queue_ref"), commit: commit, max_bytes: PreparedWork::MAX_TOTAL)
154
+ unless Digest::SHA256.hexdigest(bytes) == record.fetch("queue_sha256")
155
+ raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue bytes differ"
156
+ end
157
+ bytes
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require_relative "prepared_work"
6
+ require_relative "private_directory"
7
+ require_relative "../organisms/assignment_executor"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Authority
12
+ # A disposable projection for the existing queue engine. Only bytes read
13
+ # from (or about to be committed to) the canonical execution ref enter it.
14
+ # No files left by an earlier coordinator are recovery input.
15
+ class OrdinaryQueue
16
+ attr_reader :bytes
17
+
18
+ def initialize(work:, root:, bytes: nil)
19
+ raise ArgumentError, "ordinary queue requires prepared work" unless work.is_a?(PreparedWork)
20
+ @work, @root = work, PrivateDirectory.verify!(root)
21
+ value = if bytes
22
+ invalid! unless bytes.is_a?(String) && bytes.bytesize.between?(1, PreparedWork::MAX_TOTAL)
23
+ JSON.parse(bytes)
24
+ else
25
+ {"version" => 1, "steps" => work.manifest.fetch("steps").to_h { |step|
26
+ name = step.fetch("filename"); [name, work.files.fetch("steps/#{name}")]
27
+ }, "reports" => {}}
28
+ end
29
+ validate!(value, initial: bytes.nil?)
30
+ @bytes = Atoms::EvidenceDigest.canonical_json(value).freeze
31
+ rescue JSON::ParserError
32
+ invalid!
33
+ end
34
+
35
+ def status
36
+ value = JSON.parse(@bytes)
37
+ assignment = Models::Assignment.from_h(@work.definition, cache_dir: File.join(@root, @work.manifest.fetch("assignment_id")))
38
+ Molecules::QueueScanner.new.scan_captured(steps: value.fetch("steps"), reports: value.fetch("reports"), assignment: assignment)
39
+ end
40
+
41
+ # This returns proposed bytes only. The lifecycle owns authorization,
42
+ # generation, replay and publication together with its attempt events.
43
+ def transition(operation:, scope: @work.manifest.fetch("scope"), step: nil, report: nil, message: nil)
44
+ invalid! unless @work.manifest.fetch("steps").any? { |entry| entry.fetch("number") == scope }
45
+ value = JSON.parse(@bytes)
46
+ Dir.mktmpdir("ordinary-queue-", @root) do |scratch|
47
+ File.chmod(0o700, scratch)
48
+ directory = File.join(scratch, @work.manifest.fetch("assignment_id"))
49
+ %w[steps reports].each { |name| FileUtils.mkdir_p(File.join(directory, name), mode: 0700) }
50
+ assignment = Models::Assignment.from_h(@work.definition, cache_dir: directory)
51
+ write(File.join(directory, "assignment.yaml"), assignment.to_h.to_yaml)
52
+ write(File.join(directory, "job.yaml"), @work.files.fetch("job.yaml"))
53
+ %w[steps reports].each do |group|
54
+ value.fetch(group).each { |filename, content| write(File.join(directory, group, filename), content) }
55
+ end
56
+ executor = Organisms::AssignmentExecutor.new(cache_base: scratch, selected_assignment: assignment)
57
+ case operation
58
+ when :activate
59
+ state = executor.status.fetch(:state)
60
+ invalid! unless state.find_by_number(scope)&.status == :pending
61
+ executor.step_writer.mark_active(state.find_by_number(scope).file_path)
62
+ when :start
63
+ executor.start_step(step_number: step, fork_root: scope)
64
+ when :finish
65
+ executor.finish_step(report_content: report, step_number: step, fork_root: scope)
66
+ when :fail
67
+ executor.fail(message, fork_root: scope)
68
+ else
69
+ raise ArgumentError, "unknown ordinary queue transition"
70
+ end
71
+ result = {"version" => 1}
72
+ %w[steps reports].each do |group|
73
+ result[group] = Dir.children(File.join(directory, group)).to_h do |filename|
74
+ [filename, File.binread(File.join(directory, group, filename)).force_encoding(Encoding::UTF_8)]
75
+ end
76
+ end
77
+ self.class.new(work: @work, root: @root, bytes: Atoms::EvidenceDigest.canonical_json(result))
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def validate!(value, initial:)
84
+ invalid! unless value.is_a?(Hash) && value.keys.sort == %w[reports steps version] && value["version"].is_a?(Integer) && value["version"] == 1 &&
85
+ value["steps"].is_a?(Hash) && value["reports"].is_a?(Hash)
86
+ expected = @work.manifest.fetch("steps").map { |step| step.fetch("filename") }
87
+ invalid! unless value.fetch("steps").keys.sort == expected.sort
88
+ reports = []
89
+ value.fetch("steps").each do |filename, content|
90
+ parsed = @work.parse_queue_step!(content)
91
+ progress = parsed.fetch(:frontmatter).keys & PreparedWork::PROGRESS
92
+ allowed = initial ? %w[status] : %w[status started_at completed_at error stall_reason]
93
+ invalid! unless (progress - allowed).empty?
94
+ original = @work.files.fetch("steps/#{filename}")
95
+ invalid! unless PreparedWork.work_projection(content) == PreparedWork.work_projection(original) &&
96
+ %w[pending active done failed].include?(parsed.fetch(:frontmatter).fetch("status", "pending"))
97
+ info = Atoms::StepFileParser.parse_filename(filename)
98
+ reports << Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
99
+ end
100
+ invalid! unless (value.fetch("reports").keys - reports).empty?
101
+ value.fetch("reports").each_value { |content| @work.parse_queue_step!(content) }
102
+ invalid! if Atoms::EvidenceDigest.canonical_json(value).bytesize > PreparedWork::MAX_TOTAL
103
+ end
104
+
105
+ def write(path, content)
106
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |file| file.write(content) }
107
+ end
108
+
109
+ def invalid!
110
+ raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue differs from its accepted work"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "receipt_transfer"
4
+ require_relative "../molecules/canonical_evidence"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # A worker submits an attributable result. This never accepts a terminal
10
+ # outcome, an independent review, or an external delivery effect.
11
+ module OrdinaryResults
12
+ RESULT_OPERATIONS = %w[submit_result result_status result_snapshot].freeze
13
+ RESULT_TRANSFERS = {
14
+ "submit_result" => {direction: :upload, purpose: :receipt_artifacts, roles: %i[worker supervisor]},
15
+ "result_snapshot" => {direction: :download, purpose: :receipt_artifacts, roles: %i[worker supervisor]}
16
+ }.freeze
17
+ RESULT_KEYS = %w[assignment_id attempt_id expected_generation pane head receipt_sha256 transfer].freeze
18
+
19
+ private
20
+
21
+ def validate_result!(request)
22
+ params = request.fetch("params")
23
+ if request.fetch("operation") != "submit_result"
24
+ snapshot = request.fetch("operation") == "result_snapshot"
25
+ strict!(params, %w[assignment_id attempt_id] + (snapshot ? ["result_id"] : []))
26
+ token!(params.fetch("result_id")) if snapshot
27
+ raise ArgumentError, "ordinary result status mutation must be null" unless request.fetch("mutation_id").nil?
28
+ else
29
+ strict!(params, RESULT_KEYS)
30
+ token!(request.fetch("mutation_id"))
31
+ unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
32
+ params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
33
+ params.fetch("receipt_sha256").is_a?(String) && params.fetch("receipt_sha256").match?(/\A[0-9a-f]{64}\z/)
34
+ raise ArgumentError, "ordinary result selection is invalid"
35
+ end
36
+ end
37
+ %w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
38
+ end
39
+
40
+ def ordinary_result(request, peer, role, deadline, transfer)
41
+ validate_result!(request)
42
+ params = request.fetch("params")
43
+ commit = @journal.ref_value
44
+ registered = registration(params.fetch("assignment_id"), commit: commit)
45
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
46
+ if request.fetch("operation") != "submit_result"
47
+ events = result_events(params, commit)
48
+ result = retained_ordinary_result(params, events, commit)
49
+ if request.fetch("operation") == "result_snapshot"
50
+ unless result && result.fetch("result_id") == params.fetch("result_id")
51
+ raise AttemptErrors::NotFound, "ordinary exact result is unavailable"
52
+ end
53
+ receipt = result.fetch("receipt")
54
+ canonical = Molecules::CanonicalEvidence.new(journal: @journal)
55
+ parts = [JSON.generate(receipt)] + receipt.fetch("artifacts").map { |artifact|
56
+ canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
57
+ **ordinary_result_context(result.fetch("binding")), commit: commit)
58
+ }
59
+ authorize!(request, peer, role, deadline)
60
+ return {data: ordinary_result_projection(result).merge("journal_commit" => commit), transfer_parts: parts, replayed: false}
61
+ end
62
+ authorize!(request, peer, role, deadline)
63
+ return {data: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
64
+ "journal_commit" => commit, "generation" => @journal.authority_generation(events),
65
+ "state" => Molecules::CanonicalAttemptState.derive(events), "submitted_result" => result && ordinary_result_projection(result)}, replayed: false}
66
+ end
67
+ unless %i[worker supervisor].include?(role)
68
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary result requires its original worker"
69
+ end
70
+ authorize_start_replay!(request, peer, role, deadline)
71
+ admitted = ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
72
+ artifact_field: "artifacts", reference_key: "path")
73
+ digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
74
+ keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
75
+ @exclusion.with_shared_multi(keys, deadline: deadline) do
76
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
77
+ mutation_id: request.fetch("mutation_id"), operation: "submit_result", parameters_digest: digest,
78
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
79
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, generation|
80
+ authorize_start_replay!(request, peer, role, deadline)
81
+ unless Molecules::CanonicalAttemptState.derive(events) == "running"
82
+ raise AttemptErrors::Conflict, "ordinary result requires the original running attempt"
83
+ end
84
+ raise AttemptErrors::Conflict, "ordinary attempt already submitted its result" if retained_ordinary_result(params, events, prefix)
85
+ observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
86
+ start_replay_owner!(params, observed.fetch(:identity))
87
+ ordinary_result_plan(params, peer, admitted, observed, events, prefix, generation + 1)
88
+ end
89
+ end
90
+ end
91
+
92
+ def result_events(params, commit)
93
+ events = @journal.read_events(params.fetch("assignment_id"), commit: commit)
94
+ .select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
95
+ unless Models::EvidenceEvent.chain_valid?(events) && events.one? { |event| event["type"] == "process_start" }
96
+ raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt evidence is unavailable"
97
+ end
98
+ events
99
+ end
100
+
101
+ def ordinary_result_plan(params, peer, admitted, observed, events, commit, generation)
102
+ original = admitted.fetch(:receipt)
103
+ original_receipt = Models::ExecutionReceipt.from_h(original)
104
+ unless original_receipt.digest == Atoms::EvidenceDigest.digest(original_receipt.digest_payload)
105
+ raise AttemptErrors::ReceiptRejected, "ordinary original result digest differs"
106
+ end
107
+ identity = observed.fetch(:identity)
108
+ expected_producer = {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime}
109
+ validate_ordinary_result_receipt!(original, expected_producer)
110
+ id = SecureRandom.hex(16)
111
+ binding = params.slice("assignment_id", "attempt_id", "head").merge("authority" => "ordinary", "project_id" => @project,
112
+ "result_id" => id, "worker_uid" => peer.fetch("uid"), "producer" => expected_producer,
113
+ "runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout),
114
+ "generation" => generation, "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256),
115
+ "original_receipt_digest" => original_receipt.digest)
116
+ canonical = Molecules::CanonicalEvidence.new(journal: @journal)
117
+ plan = admitted.fetch(:artifacts).empty? ? {events: [], blobs: {}, references: []} :
118
+ canonical.import_plan(**ordinary_result_context(binding), artifacts: admitted.fetch(:artifacts),
119
+ admitted_after_event_digest: events.last.fetch("digest"))
120
+ receipt = original.except("digest", "recorded_at").merge("artifacts" => plan.fetch(:references).map { |reference|
121
+ {"path" => reference.fetch("ref"), "sha256" => reference.fetch("sha256")}
122
+ })
123
+ pending = @journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
124
+ reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
125
+ **ordinary_result_context(binding), current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: commit) }
126
+ verified = verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
127
+ if JSON.generate(verified.to_h).bytesize > 16_384
128
+ raise AttemptErrors::ReceiptRejected, "canonical ordinary receipt exceeds its transfer limit"
129
+ end
130
+ payload = {"authority" => "ordinary", "result_id" => id, "binding" => binding, "receipt" => verified.to_h}
131
+ plan.merge(events: plan.fetch(:events) + [{type: "result_submitted", payload: payload}],
132
+ data: ordinary_result_projection(payload))
133
+ end
134
+
135
+ def ordinary_result_projection(payload)
136
+ payload.slice("result_id").merge(payload.fetch("binding").slice("head", "generation"),
137
+ "receipt_digest" => payload.fetch("receipt").fetch("digest"), "verdict" => payload.fetch("receipt").fetch("verdict"),
138
+ "state" => "running", "authority" => "ordinary")
139
+ end
140
+
141
+ def ordinary_result_context(binding)
142
+ {kind: "result", project_id: @project, assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
143
+ peer_uid: binding.fetch("worker_uid"), binding: binding, request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("generation")}
144
+ end
145
+
146
+ def validate_ordinary_result_receipt!(receipt, producer)
147
+ unless receipt["producer"] == producer && (receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
148
+ !receipt["campaign"] && !receipt["review"] && receipt["operation"] != "review" &&
149
+ !Molecules::ReceiptVerifier::EXTERNAL_EFFECT_OPERATIONS.include?(receipt["operation"])
150
+ raise AttemptErrors::ReceiptRejected, "ordinary result producer or operation requires another evidence owner"
151
+ end
152
+ end
153
+
154
+ def verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
155
+ validate_ordinary_result_receipt!(receipt, binding.fetch("producer"))
156
+ attempt = @journal.derived_attempts(params.fetch("assignment_id"), commit: commit).find { |item| item.attempt_id == params.fetch("attempt_id") }
157
+ raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt is unavailable" unless attempt
158
+ Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
159
+ Molecules::ReceiptVerifier.new(artifact_reader: reader).verify_result!(receipt,
160
+ attempt: attempt, live_head: binding.fetch("head"), repo_root: @journal.repo_root)
161
+ end
162
+
163
+ def retained_ordinary_result(params, events, commit)
164
+ raise AttemptErrors::EvidenceUnavailable, "ordinary result chain is corrupt" unless Models::EvidenceEvent.chain_valid?(events)
165
+ records = events.select { |event| event["type"] == "result_submitted" }
166
+ replies = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "submit_result" }
167
+ return nil if records.empty? && replies.empty?
168
+ unless records.one? && replies.one?
169
+ raise AttemptErrors::EvidenceUnavailable, "ordinary result provenance is missing or duplicated"
170
+ end
171
+ payload = records.first.fetch("payload")
172
+ binding = payload.fetch("binding")
173
+ start = events.find { |event| event["type"] == "process_start" }.fetch("payload")
174
+ unless payload.keys.sort == %w[authority binding receipt result_id] && payload["authority"] == "ordinary" &&
175
+ payload["result_id"].is_a?(String) && payload["result_id"].match?(/\A[0-9a-f]{32}\z/) &&
176
+ binding.keys.sort == %w[assignment_id attempt_id authority checkout generation head original_receipt_digest producer project_id result_id runtime_binding uploaded_receipt_sha256 worker_uid] &&
177
+ binding["authority"] == "ordinary" && binding["result_id"] == payload["result_id"] &&
178
+ binding["head"] == binding.dig("checkout", "head") &&
179
+ binding.values_at("project_id", "assignment_id", "attempt_id", "runtime_binding") ==
180
+ [@project, params.fetch("assignment_id"), params.fetch("attempt_id"), start.fetch("runtime_binding")] &&
181
+ binding.fetch("producer") == start.slice("actor", "role", "runtime") &&
182
+ binding.fetch("worker_uid") == start.dig("runtime_binding", "process_identity", "uid") &&
183
+ payload.fetch("receipt").fetch("producer") == binding.fetch("producer") &&
184
+ replies.first.dig("payload", "data").slice(*ordinary_result_projection(payload).keys) == ordinary_result_projection(payload)
185
+ raise AttemptErrors::EvidenceUnavailable, "ordinary result owner differs"
186
+ end
187
+ canonical = Molecules::CanonicalEvidence.new(journal: @journal)
188
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
189
+ **ordinary_result_context(binding), commit: commit) }
190
+ verify_ordinary_result_receipt(params, payload.fetch("receipt"), binding, commit, reader)
191
+ payload
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket_server"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # Explicit ordinary admission over Assign's existing wire/transfer owner.
9
+ # The Lab composition supplies a trusted installed admission object and
10
+ # closed handlers. No protected Deployment or kernel adapter is replaced.
11
+ class OrdinaryServer < SocketServer
12
+ def initialize(admission:, lifecycle:, services: [])
13
+ @admission = admission
14
+ super(service: admission.service, lifecycle: lifecycle, services: services)
15
+ end
16
+
17
+ private
18
+
19
+ def verify_listener!
20
+ @admission.verify_listener!
21
+ end
22
+
23
+ def authorize_transfer!(request:, peer:, role:, deadline:)
24
+ @lifecycle.authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
25
+ end
26
+
27
+ def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
28
+ @lifecycle.dispatch(request: request, peer: peer, role: role, deadline: deadline, transfer: transfer)
29
+ end
30
+
31
+ def receive(socket)
32
+ deadline = wire.deadline(30)
33
+ peer = @admission.peer(socket, deadline: deadline)
34
+ ingress = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
35
+ frame = wire.read(ingress, deadline: [deadline, wire.deadline(5)].min, limit: 16_384, with_size: true)
36
+ request = frame.fetch(:data)
37
+ unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
38
+ request["version"].is_a?(Integer) && request["version"] == 1 &&
39
+ request["operation"].is_a?(String) && request["params"].is_a?(Hash)
40
+ raise ArgumentError, "invalid ordinary authority envelope"
41
+ end
42
+ role = @admission.authorize!(request: request, peer: peer, deadline: deadline)
43
+ binding = @lifecycle.transfer_binding(request)
44
+ bodyless_read!(ingress, deadline) unless binding && binding.fetch(:direction) == :upload
45
+ # Both binary bodies and EOF use ancillary-rejecting reads. Replies
46
+ # use the original duplex socket through the wrapper below.
47
+ dispatch_request(Duplex.new(socket, ingress), request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
48
+ rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
49
+ refusal(socket, "invalid_input")
50
+ rescue AttemptErrors::UnauthorizedIdentity, SecurityError
51
+ refusal(socket, "unauthorized")
52
+ rescue AttemptErrors::Conflict
53
+ refusal(socket, "conflict")
54
+ rescue AttemptErrors::NotFound
55
+ refusal(socket, "missing")
56
+ rescue AttemptErrors::BoundedResultUnavailable
57
+ refusal(socket, "bounded_result")
58
+ rescue StandardError
59
+ refusal(socket, "evidence_unavailable")
60
+ ensure
61
+ socket.close unless socket.closed?
62
+ @mutex.synchronize { @connections.delete(socket) }
63
+ end
64
+
65
+ class Duplex
66
+ def initialize(socket, ingress) = (@socket, @ingress = socket, ingress)
67
+ def to_io = @socket
68
+ def read_nonblock(*args, **options) = @ingress.read_nonblock(*args, **options)
69
+ def write_nonblock(*args, **options) = @socket.write_nonblock(*args, **options)
70
+ end
71
+ private_constant :Duplex
72
+ end
73
+ end
74
+ end
75
+ end
@@ -69,9 +69,9 @@ module Ace
69
69
  # The existing transport verifies complete Git objects before this
70
70
  # shared byte contract parses anything. Preserve the original input for
71
71
  # the eventual registration journal; CandidateTransfer may reexport it.
72
- def self.admit(bytes:, head:, tree:, sha256:, size:, root:)
72
+ def self.admit(bytes:, head:, tree:, sha256:, size:, root:, deadline: nil)
73
73
  transfer = CandidateTransfer.new(root: root)
74
- transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size) do |repository, deadline, result|
74
+ transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size, deadline: deadline) do |repository, deadline, result|
75
75
  raise ArgumentError, "prepared_input_invalid: Git tree differs" unless result.fetch("tree") == tree
76
76
  new(files: transfer.prepared_files(repository: repository, deadline: deadline, head: head))
77
77
  end
@@ -12,7 +12,7 @@ module Ace
12
12
  class ProtectedAssignmentContext
13
13
  attr_reader :uid
14
14
 
15
- def self.load
15
+ def self.load(uid: Process.uid)
16
16
  paths = [Deployment::PATH, DeploymentHistory::PATH]
17
17
  installed = paths.any? do |path|
18
18
  begin
@@ -22,13 +22,13 @@ module Ace
22
22
  false
23
23
  end
24
24
  end
25
- return new(deployment: nil, history: nil) unless installed
25
+ return new(deployment: nil, history: nil, uid: uid) unless installed
26
26
  deployment = Deployment.load
27
27
  history = DeploymentHistory.load
28
28
  unless history.selects?(deployment)
29
29
  raise AttemptErrors::EvidenceUnavailable, "installed protected descriptor is not retained"
30
30
  end
31
- new(deployment: deployment, history: history)
31
+ new(deployment: deployment, history: history, uid: uid)
32
32
  end
33
33
 
34
34
  def initialize(deployment:, history:, uid: Process.uid, kernel: nil, env: ENV,
@@ -4,10 +4,10 @@ module Ace
4
4
  module Authority
5
5
  # Source-owned handlers share one listener. Requests never name a method.
6
6
  class Router
7
- def initialize(launch:, handlers: [])
7
+ def initialize(launch: nil, handlers: [])
8
8
  @launch = launch
9
9
  @routes = {}
10
- ([launch] + handlers).each do |handler|
10
+ ([launch].compact + handlers).each do |handler|
11
11
  handler.class::OPERATIONS.each do |operation|
12
12
  raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
13
13
  @routes[operation] = handler
@@ -16,11 +16,12 @@ module Ace
16
16
  @routes.freeze
17
17
  end
18
18
 
19
- def dispatch(request:, peer:, role:, transfer: nil)
19
+ def dispatch(request:, peer:, role:, transfer: nil, deadline: nil)
20
20
  validate_review_namespace!(request)
21
21
  handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
22
22
  options = {request: request, peer: peer, role: role}
23
23
  options[:transfer] = transfer if transfer
24
+ options[:deadline] = deadline if deadline
24
25
  handler.dispatch(**options)
25
26
  end
26
27
 
@@ -42,10 +43,12 @@ module Ace
42
43
  binding
43
44
  end
44
45
 
45
- def authorize_transfer!(request:, peer:, role:)
46
+ def authorize_transfer!(request:, peer:, role:, deadline: nil)
46
47
  validate_review_namespace!(request)
47
48
  handler = @routes.fetch(request.fetch("operation"))
48
- handler.authorize_transfer!(request: request, peer: peer, role: role)
49
+ options = {request: request, peer: peer, role: role}
50
+ options[:deadline] = deadline if deadline
51
+ handler.authorize_transfer!(**options)
49
52
  end
50
53
 
51
54
  def validate_review_namespace!(request)
@@ -60,14 +63,16 @@ module Ace
60
63
  private :validate_review_namespace!
61
64
 
62
65
  def close
63
- @launch.close
66
+ @routes.values.uniq.each { |handler| handler.close if handler.respond_to?(:close) }
64
67
  end
65
68
 
66
69
  def serve_launch_control!(**options)
70
+ raise AttemptErrors::EvidenceUnavailable, "launch control is unavailable in this composition" unless @launch
67
71
  @launch.serve_launch_control!(**options)
68
72
  end
69
73
 
70
74
  def gate_ready(**options)
75
+ raise AttemptErrors::EvidenceUnavailable, "launch gate is unavailable in this composition" unless @launch
71
76
  @launch.gate_ready(**options)
72
77
  end
73
78
  end