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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ordinary_peer"
4
+ require_relative "transfer_codec"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Installed selection is supplied by the caller's trusted composition.
10
+ # This client never selects a journal, local fallback or execution identity.
11
+ class OrdinaryClient
12
+ Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
13
+
14
+ def initialize(project_id:, service:, verify_selection:, peer: OrdinaryPeer.new, scratch_root: nil)
15
+ @project_id, @service, @verify_selection, @peer, @scratch_root = project_id, service, verify_selection, peer, scratch_root
16
+ end
17
+
18
+ def call(operation, params, mutation_id: nil, upload_parts: nil, download: false, purpose: nil, timeout: 30)
19
+ unless operation.is_a?(String) && params.is_a?(Hash) && !params.key?("transfer") &&
20
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
21
+ raise ArgumentError, "invalid ordinary authority request"
22
+ end
23
+ raise ArgumentError, "ordinary download needs private scratch and forbids upload" if download && (upload_parts || !@scratch_root)
24
+ @verify_selection.call
25
+ wire = Ace::Runtime::Molecules::ProtectedSocket
26
+ path = @service.fetch("socket_path")
27
+ wire.root_path!(File.dirname(path), directory: true, owner: @service.fetch("uid"))
28
+ before = wire.socket_identity(path, mode: 0o660)
29
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint owner differs" unless before.last == @service.fetch("uid")
30
+ codec = TransferCodec.new(root: download ? @scratch_root : nil)
31
+ descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
32
+ parameters = descriptor ? params.merge("transfer" => descriptor) : params
33
+ deadline = wire.deadline([timeout, 30].min)
34
+ wire.connect(path, deadline: deadline) do |socket|
35
+ observed = @peer.capture(socket, deadline: deadline)
36
+ unless observed.values_at("uid", "gid") == @service.values_at("uid", "gid") && wire.socket_identity(path, mode: 0o660) == before
37
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint peer changed"
38
+ end
39
+ @verify_selection.call
40
+ wire.write(socket, {"version" => 1, "operation" => operation, "mutation_id" => mutation_id,
41
+ "project_id" => @project_id, "params" => parameters}, deadline: deadline, limit: 16_384)
42
+ codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline) if upload_parts
43
+ socket.shutdown(Socket::SHUT_WR)
44
+ ingress = wire::Ingress.new(socket)
45
+ result = wire.read(ingress, deadline: deadline, limit: 16_384)
46
+ unless result.is_a?(Hash) && result.keys.sort == %w[data status transport] && result["status"] == "ok" &&
47
+ result["data"].is_a?(Hash) && result["transport"].is_a?(Hash) &&
48
+ result["transport"].keys == ["replayed"] && [true, false].include?(result["transport"]["replayed"])
49
+ code = result.is_a?(Hash) && result["error"].is_a?(Hash) ? result["error"]["code"] : nil
50
+ raise AttemptErrors::EvidenceUnavailable, "ordinary authority refused (#{code || 'invalid_response'}); retain original mutation"
51
+ end
52
+ parts = if download
53
+ codec.receive(ingress, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
54
+ Array.new(input.count) { |index| input.bytes(index: index) }
55
+ end
56
+ end
57
+ @verify_selection.call
58
+ @peer.live!(observed, deadline: deadline)
59
+ unless wire.socket_identity(path, mode: 0o660) == before
60
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint changed before response acceptance"
61
+ end
62
+ Reply.new(data: result.fetch("data"), replayed: result.fetch("transport").fetch("replayed"), parts: parts)
63
+ end
64
+ rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
65
+ raise AttemptErrors::EvidenceUnavailable, "ordinary authority outcome unavailable; retain original mutation; no local fallback"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ordinary_effect_settlement"
4
+ require_relative "../organisms/attempt_coordinator"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Terminal acceptance consumes the submitted canonical receipt. The
10
+ # supervisor cannot replace its bytes, worker, scope or candidate.
11
+ module OrdinaryCompletion
12
+ FINISH_KEYS = %w[assignment_id attempt_id expected_generation pane head result_id receipt_digest].freeze
13
+
14
+ private
15
+
16
+ def finish_result(request, peer, role, deadline)
17
+ params = request.fetch("params")
18
+ strict!(params, FINISH_KEYS)
19
+ %w[assignment_id attempt_id result_id].each { |key| token!(params.fetch(key)) }
20
+ token!(request.fetch("mutation_id"))
21
+ unless role == :supervisor
22
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary completion requires the installed supervisor"
23
+ end
24
+ unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
25
+ params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
26
+ params.fetch("receipt_digest").is_a?(String) && params.fetch("receipt_digest").match?(/\A[0-9a-f]{64}\z/)
27
+ raise ArgumentError, "ordinary completion selection is invalid"
28
+ end
29
+ registered = registration(params.fetch("assignment_id"), commit: @journal.ref_value)
30
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
31
+ digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
32
+ @exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id"))], deadline: deadline) do
33
+ @exclusion.with_exclusive(@exclusion.assignment_key(params.fetch("assignment_id")), deadline: deadline) do
34
+ authorize!(request, peer, role, deadline)
35
+ with_result_transport(request, deadline) do |transport|
36
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
37
+ mutation_id: request.fetch("mutation_id"), operation: "finish", parameters_digest: digest,
38
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
39
+ replay_authorizer: -> { authorize!(request, peer, role, deadline) }) do |events, prefix, _generation|
40
+ authorize!(request, peer, role, deadline)
41
+ unless Molecules::CanonicalAttemptState.derive(events) == "running"
42
+ raise AttemptErrors::Conflict, "ordinary completion requires a running attempt"
43
+ end
44
+ result = retained_ordinary_result(params, events, prefix)
45
+ unless result && result.fetch("result_id") == params.fetch("result_id") &&
46
+ result.dig("receipt", "digest") == params.fetch("receipt_digest") && result.dig("binding", "head") == params.fetch("head")
47
+ raise AttemptErrors::ReceiptRejected, "ordinary completion requires its exact submitted result"
48
+ end
49
+ binding = result.fetch("binding")
50
+ observed = @admission.recorded_execution!(binding: binding.fetch("runtime_binding"), head: binding.fetch("head"), deadline: deadline)
51
+ unless observed.fetch(:runtime_binding) == binding.fetch("runtime_binding") && observed.fetch(:checkout) == binding.fetch("checkout")
52
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary result worker checkout changed"
53
+ end
54
+ references = events.select { |event| event["type"] == "inbox_binding" }.map { |event| event.fetch("payload") }
55
+ delivery = transport.call(references: references, attempt_id: params.fetch("attempt_id"), owner: binding.fetch("runtime_binding"))
56
+ scope = events.find { |event| event["type"] == "intent" }.fetch("payload").fetch("scope")
57
+ work = registered_work(registered, prefix, deadline)
58
+ queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(current_queue_record(registered, prefix), prefix))
59
+ verdict = result.dig("receipt", "verdict")
60
+ complete = verdict == "succeeded" ? queue.status.subtree_complete?(scope) : queue.status.subtree_failed?(scope)
61
+ raise AttemptErrors::Conflict, "ordinary result scope has unfinished progress" unless complete
62
+ settlement = OrdinaryEffectSettlement.new(journal: @journal).verify!(project: @project,
63
+ assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), events: events, commit: prefix)
64
+ authorize!(request, peer, role, deadline)
65
+ plan = Organisms::AttemptCoordinator.finished_transition_plan(events: events, receipt: result.fetch("receipt"), authority: :ordinary)
66
+ plan.merge(data: params.slice("assignment_id", "attempt_id", "result_id", "head", "receipt_digest").merge(
67
+ "authority" => "ordinary", "scope" => scope, "state" => verdict, "settlement" => settlement, "inbox_deliveries" => delivery))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ rescue Ace::Herdr::Error => error
73
+ raise AttemptErrors::EvidenceUnavailable, "ordinary transport settlement is unavailable: #{error.message}"
74
+ end
75
+
76
+ def with_result_transport(request, deadline)
77
+ if @journal.mutation_result(request.fetch("mutation_id"), commit: @journal.ref_value)
78
+ # A terminal reply is immutable. Current peer authority is still
79
+ # checked by mutate, but retired transport files are not replay input.
80
+ return yield nil
81
+ end
82
+ if @inbox
83
+ @inbox.with_delivery_settlement(deadline: deadline) { |verify| yield verify }
84
+ else
85
+ yield lambda { |references:, **_|
86
+ raise AttemptErrors::EvidenceUnavailable, "ordinary Inbox owner is not installed" unless references.empty?
87
+ []
88
+ }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "service_evidence"
4
+ require_relative "../molecules/ordinary_merge_contract"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Completion reads the existing effect owners at the held CAS prefix.
10
+ # A prompt, acknowledgment or failed dispatch never settles an effect.
11
+ class OrdinaryEffectSettlement
12
+ def initialize(journal:)
13
+ @journal = journal
14
+ end
15
+
16
+ def verify!(project:, assignment:, attempt:, events:, commit:)
17
+ retained = @journal.read_events(assignment, commit: commit).select { |event| event["attempt_id"] == attempt }
18
+ unless retained == events && Models::EvidenceEvent.chain_valid?(events)
19
+ unavailable!("ordinary settlement needs its exact canonical attempt")
20
+ end
21
+ selection = {"project_id" => project, "assignment_id" => assignment, "attempt_id" => attempt}
22
+ services = records!(@journal.service_request_records(commit: commit), events, selection, %w[service_claim service_transition])
23
+ merges = records!(@journal.fixed_merge_request_records(commit: commit), events, selection, %w[merge_admission merge_transition])
24
+ services.each { |record| service!(record, commit) }
25
+ merges.each { |record| merge!(record, commit) }
26
+ assignment_events = @journal.read_events(assignment, commit: commit)
27
+ assignment_events.group_by { |event| event.fetch("attempt_id") }.each_value do |chain|
28
+ unavailable!("ordinary delivery history is corrupt") unless Models::EvidenceEvent.chain_valid?(chain)
29
+ end
30
+ delivery!(events, merges, assignment_events)
31
+ {"journal_commit" => commit, "service_requests" => services.map { |record| record.fetch("request_id") }.sort,
32
+ "merge_requests" => merges.map { |record| record.fetch("request_id") }.sort}
33
+ end
34
+
35
+ private
36
+
37
+ def records!(inventory, events, selection, types)
38
+ records = inventory.select { |record| record.values_at("assignment_id", "attempt_id") == selection.values_at("assignment_id", "attempt_id") }
39
+ references = events.select { |event| types.include?(event["type"]) }
40
+ ids = references.map { |event| event.fetch("payload").fetch("request_id") }.uniq.sort
41
+ unless records.map { |record| record.fetch("request_id") }.sort == ids
42
+ unavailable!("ordinary effect inventory and event set differ")
43
+ end
44
+ records.each do |record|
45
+ latest = references.reverse.find { |event| event.dig("payload", "request_id") == record.fetch("request_id") }
46
+ unless record.slice(*selection.keys) == selection && latest.dig("payload", "state") == record["state"] &&
47
+ latest.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record) &&
48
+ latest.dig("payload", "receipt_digest") == (record["receipt"] && Atoms::EvidenceDigest.digest(record["receipt"]))
49
+ unavailable!("ordinary effect record differs from its accepted owner event")
50
+ end
51
+ end
52
+ records
53
+ end
54
+
55
+ def service!(record, commit)
56
+ return if record["state"] == "rejected" && record["consumed"] == false
57
+ pending!("ordinary service effect is unresolved") unless %w[succeeded failed-settled].include?(record["state"])
58
+ receipt = record.fetch("receipt")
59
+ @journal.validate_terminal_receipt_structure!(record, record.fetch("state"), receipt)
60
+ # The installed owner consumes the existing canonical import, never
61
+ # the old local receipt file. This is independent of journal mode.
62
+ owner = ServiceEvidence.new(journal: @journal)
63
+ references = receipt.fetch("evidence")
64
+ if %w[publish prune-preserved-workspace].include?(record["operation"])
65
+ contents = owner.call(references, record, record.fetch("state"), {commit: commit})
66
+ unless contents.is_a?(Array) && contents.size == references.size
67
+ unavailable!("ordinary service evidence collection differs")
68
+ end
69
+ else
70
+ contents = references.map { |reference| owner.call(reference, record, record.fetch("state"), {commit: commit}) }
71
+ end
72
+ references.zip(contents).each do |reference, bytes|
73
+ unavailable!("ordinary service evidence digest differs") unless bytes.is_a?(String) && Digest::SHA256.hexdigest(bytes.b) == reference.fetch("sha256")
74
+ @journal.verify_service_attestation!(bytes, record, record.fetch("state")) unless %w[publish prune-preserved-workspace].include?(record["operation"])
75
+ end
76
+ end
77
+
78
+ def merge!(record, commit)
79
+ unless @journal.fixed_merge_settlement!(record.fetch("request_id"), commit: commit) == record
80
+ unavailable!("ordinary merge settlement differs from its selected record")
81
+ end
82
+ end
83
+
84
+ def delivery!(events, merges, assignment_events)
85
+ deliveries = assignment_events.select { |event| event["type"] == "delivery" }
86
+ events.select { |event| event["type"] == "delivery" }.each do |intent|
87
+ payload = intent.fetch("payload")
88
+ case payload["stage"]
89
+ when "intent"
90
+ settled = deliveries.any? { |event| event.dig("payload", "stage") == "result" &&
91
+ event.dig("payload", "intent_digest") == intent.fetch("digest") &&
92
+ event.dig("payload", "intent_attempt_id") == intent.fetch("attempt_id") &&
93
+ event.dig("payload", "operation") == payload.fetch("operation") &&
94
+ event.dig("payload", "head") == payload.fetch("head") && event.dig("payload", "outcome") == "succeeded" }
95
+ pending!("ordinary delivery intent is unresolved") unless settled
96
+ when "ordinary-merge-intent"
97
+ result = deliveries.reverse.find { |event| event.dig("payload", "stage") == "ordinary-merge-result" &&
98
+ event.dig("payload", "request_id") == payload.fetch("request_id") && event.dig("payload", "input_digest") == payload.fetch("input_digest") }
99
+ pending!("ordinary merge delivery is unresolved") unless result
100
+ accepted = result.fetch("payload")
101
+ record = merges.find { |item| item["request_id"] == payload.fetch("request_id") }
102
+ if accepted["outcome"] == "refused_no_effect" && record.nil?
103
+ refusal = accepted.slice("code", "message", "request_id", "input_digest").merge("schema" => "ace.lab.service.ordinary-merge-refusal/v1")
104
+ Molecules::OrdinaryMergeContract.validate_refusal!(refusal)
105
+ pending!("ordinary merge receiver availability is unresolved") if refusal["code"] == Molecules::OrdinaryMergeContract::UNAVAILABLE_CODE
106
+ elsif !record || record["input_digest"] != payload.fetch("input_digest") || record["receipt"] != accepted["receipt"]
107
+ unavailable!("ordinary delivery result differs from the merge owner")
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def unavailable!(message) = raise AttemptErrors::EvidenceUnavailable, message
114
+ def pending!(message) = raise AttemptErrors::ServiceSettlementPending, message
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr"
4
+ require_relative "prepared_work"
5
+ require_relative "assignment_definition"
6
+ require_relative "ordinary_queue"
7
+ require_relative "ordinary_attempt_start"
8
+ require_relative "ordinary_progress"
9
+ require_relative "ordinary_results"
10
+ require_relative "ordinary_completion"
11
+ require_relative "../molecules/lifecycle_exclusion"
12
+
13
+ module Ace
14
+ module Assign
15
+ module Authority
16
+ # Ordinary lifecycle uses the existing canonical execution ref and CAS.
17
+ # The installed Lab composition supplies one journal and exclusion owner
18
+ # shared with HITL and merge. No requester cache supplies accepted state.
19
+ class OrdinaryLifecycle
20
+ include OrdinaryAttemptStart
21
+ include OrdinaryProgress
22
+ include OrdinaryResults
23
+ include OrdinaryCompletion
24
+ OPERATIONS = (%w[register_assignment registration_status start_attempt finish] + OrdinaryProgress::PROGRESS_OPERATIONS + OrdinaryResults::RESULT_OPERATIONS).freeze
25
+ TRANSFER_OPERATIONS = {"register_assignment" => {direction: :upload, purpose: :candidate, roles: [:supervisor]}}
26
+ .merge(OrdinaryProgress::PROGRESS_TRANSFERS).merge(OrdinaryResults::RESULT_TRANSFERS).freeze
27
+ REGISTER_KEYS = %w[assignment_id definition_digest prepared_head prepared_tree manifest_sha256 expected_generation pane transfer].freeze
28
+
29
+ def initialize(project:, admission:, journal:, exclusion:, input_root:, inbox: nil)
30
+ @project, @admission, @journal, @exclusion = project, admission, journal, exclusion
31
+ @input_root = PrivateDirectory.verify!(input_root)
32
+ unless inbox.nil? || inbox.is_a?(Ace::Herdr::Organisms::Inbox)
33
+ raise ArgumentError, "ordinary completion requires the installed Inbox owner"
34
+ end
35
+ @inbox = inbox
36
+ end
37
+
38
+ def authorize_transfer!(request:, peer:, role:, deadline:)
39
+ authorize!(request, peer, role, deadline)
40
+ if OrdinaryResults::RESULT_TRANSFERS.key?(request.fetch("operation"))
41
+ validate_result!(request)
42
+ authorize_start_replay!(request, peer, role, deadline) if request.fetch("operation") == "submit_result"
43
+ return
44
+ end
45
+ if OrdinaryProgress::PROGRESS_TRANSFERS.key?(request.fetch("operation"))
46
+ validate_progress!(request)
47
+ return
48
+ end
49
+ unless request.fetch("operation") == "register_assignment" && role == :supervisor
50
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary registration requires the installed supervisor"
51
+ end
52
+ validate_registration!(request)
53
+ end
54
+
55
+ def dispatch(request:, peer:, role:, deadline:, transfer: nil)
56
+ authorize!(request, peer, role, deadline)
57
+ case request.fetch("operation")
58
+ when "registration_status"
59
+ strict!(request.fetch("params"), %w[assignment_id])
60
+ raise ArgumentError, "status mutation must be null" unless request.fetch("mutation_id").nil?
61
+ assignment = request.fetch("params").fetch("assignment_id")
62
+ token!(assignment)
63
+ commit = @journal.ref_value
64
+ data = registration(assignment, commit: commit) || {"assignment_id" => assignment, "generation" => 0}
65
+ {data: data.merge("journal_commit" => commit), replayed: false}
66
+ when "register_assignment"
67
+ authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
68
+ register(request, peer, deadline, transfer)
69
+ when "start_attempt"
70
+ raise ArgumentError, "ordinary start forbids transfer" if transfer
71
+ start_attempt(request, peer, role, deadline)
72
+ when "assignment_snapshot"
73
+ raise ArgumentError, "ordinary snapshot forbids upload" if transfer
74
+ assignment_snapshot(request, peer, role, deadline)
75
+ when "queue_start", "queue_finish", "queue_fail"
76
+ progress(request, peer, role, deadline, transfer)
77
+ when "submit_result", "result_status", "result_snapshot"
78
+ ordinary_result(request, peer, role, deadline, transfer)
79
+ when "finish"
80
+ raise ArgumentError, "ordinary completion forbids transfer" if transfer
81
+ finish_result(request, peer, role, deadline)
82
+ else
83
+ raise ArgumentError, "unknown ordinary lifecycle operation"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def register(request, peer, deadline, transfer)
90
+ params = request.fetch("params")
91
+ # Native observation and candidate subprocesses happen before CAS.
92
+ identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
93
+ unless identity.role == "coordinator"
94
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary registration native owner differs"
95
+ end
96
+ raise AttemptErrors::MalformedTransfer, "prepared input requires one bundle" unless transfer && transfer.count == 1
97
+ bytes = transfer.bytes.freeze
98
+ descriptor = params.fetch("transfer")
99
+ work = PreparedWork.admit(bytes: bytes, head: params.fetch("prepared_head"), tree: params.fetch("prepared_tree"),
100
+ sha256: descriptor.fetch("sha256"), size: descriptor.fetch("bytes"), root: @input_root, deadline: deadline)
101
+ unless work.manifest.values_at("assignment_id", "project_id") == [params.fetch("assignment_id"), @project] &&
102
+ work.manifest_sha256 == params.fetch("manifest_sha256")
103
+ raise ArgumentError, "ordinary prepared assignment association differs"
104
+ end
105
+ definition = work.definition_bytes(head: params.fetch("prepared_head"), tree: params.fetch("prepared_tree"))
106
+ assignment = AssignmentDefinition.parse(bytes: definition, digest: params.fetch("definition_digest"),
107
+ assignment_id: params.fetch("assignment_id"), project_id: @project)
108
+ # Campaign registration needs its existing canonical campaign owner;
109
+ # it cannot be manufactured by attaching unchecked parent metadata.
110
+ if assignment.review_campaign || assignment.campaign_execution
111
+ raise AttemptErrors::EvidenceUnavailable, "ordinary campaign registration requires its campaign owner"
112
+ end
113
+ principal = {"uid" => peer.fetch("uid"), "actor" => identity.actor}
114
+ queue = OrdinaryQueue.new(work: work, root: @input_root)
115
+ queue_sha = Digest::SHA256.hexdigest(queue.bytes)
116
+ digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "principal" => principal})
117
+ keys = [@exclusion.task_key(assignment.task_id), @exclusion.assignment_key(assignment.id)]
118
+ @exclusion.with_shared_multi(keys, deadline: deadline) do
119
+ # Replay bypasses the CAS callback; current authority is mandatory
120
+ # before that lookup as well as on every fresh CAS attempt.
121
+ authorize!(request, peer, :supervisor, deadline)
122
+ @journal.mutate(assignment_id: assignment.id, attempt_id: "definition-#{assignment.id}",
123
+ mutation_id: request.fetch("mutation_id"), operation: "register_assignment", parameters_digest: digest,
124
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
125
+ replay_authorizer: -> { authorize!(request, peer, :supervisor, deadline) }) do |events, commit, _generation|
126
+ authorize!(request, peer, :supervisor, deadline)
127
+ unless events.empty? && registration(assignment.id, commit: commit).nil?
128
+ raise AttemptErrors::Conflict, "ordinary assignment is already registered; retain its original definition"
129
+ end
130
+ bundle_sha = Digest::SHA256.hexdigest(bytes)
131
+ definition_path = "execution/definitions/#{assignment.id}-#{params.fetch('definition_digest')}.json"
132
+ bundle_path = "execution/prepared/#{assignment.id}-#{bundle_sha}.bundle"
133
+ queue_path = "execution/definitions/#{assignment.id}-queue-#{queue_sha}.json"
134
+ {events: [], blobs: {definition_path => definition, bundle_path => bytes, queue_path => queue.bytes}, data: {
135
+ "authority" => "ordinary", "assignment_id" => assignment.id, "project_id" => @project,
136
+ "task_id" => assignment.task_id, "registered_by" => principal, "phase" => "registered",
137
+ "definition_ref" => definition_path, "definition_digest" => params.fetch("definition_digest"),
138
+ "queue_ref" => queue_path, "queue_sha256" => queue_sha,
139
+ "prepared_work" => work.reference(head: params.fetch("prepared_head"), tree: params.fetch("prepared_tree")),
140
+ "prepared_bundle_ref" => bundle_path, "prepared_bundle_sha256" => bundle_sha,
141
+ "prepared_bundle_bytes" => bytes.bytesize}}
142
+ end
143
+ end
144
+ end
145
+
146
+ def registration(assignment, commit:)
147
+ return nil unless commit
148
+ events = @journal.read_events(assignment, commit: commit)
149
+ events.group_by { |event| event.fetch("attempt_id") }.each_value do |chain|
150
+ raise AttemptErrors::EvidenceUnavailable, "ordinary assignment evidence is corrupt" unless Models::EvidenceEvent.chain_valid?(chain)
151
+ end
152
+ registrations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "register_assignment" }
153
+ return nil if registrations.empty?
154
+ data = registrations.one? && registrations.first.dig("payload", "data")
155
+ unless data && data.values_at("authority", "assignment_id", "project_id") == ["ordinary", assignment, @project]
156
+ raise AttemptErrors::EvidenceUnavailable, "ordinary assignment registration owner differs"
157
+ end
158
+ data
159
+ end
160
+
161
+ def authorize!(request, peer, role, deadline)
162
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
163
+ raise AttemptErrors::EvidenceUnavailable, "ordinary lifecycle deadline elapsed"
164
+ end
165
+ unless request.fetch("project_id") == @project && @admission.authorize!(request: request, peer: peer, deadline: deadline) == role
166
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary lifecycle principal differs"
167
+ end
168
+ end
169
+
170
+ def validate_registration!(request)
171
+ params = request.fetch("params")
172
+ strict!(params, REGISTER_KEYS)
173
+ token!(request.fetch("mutation_id"))
174
+ token!(params.fetch("assignment_id"))
175
+ token!("definition-#{params.fetch('assignment_id')}")
176
+ unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation") >= 0
177
+ raise ArgumentError, "ordinary generation is invalid"
178
+ end
179
+ end
180
+
181
+ def strict!(value, keys)
182
+ raise ArgumentError, "ordinary authority fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
183
+ end
184
+
185
+ def token!(value)
186
+ raise ArgumentError, "ordinary identity is invalid" unless value.is_a?(String) && Molecules::JournalMutation::ID.match?(value)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "ace/runtime/molecules/process_identity"
5
+ require "ace/herdr/molecules/bounded_observation"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Ordinary UNIX peer attribution. This is not a protected launch observer:
11
+ # it confers no Yama, capability, cgroup or descendant-closure guarantee.
12
+ class OrdinaryPeer
13
+ def initialize(identity: nil)
14
+ @identity = identity
15
+ end
16
+
17
+ def capture(socket, deadline: default_deadline)
18
+ identity = identity_observer(deadline)
19
+ uid, gid = socket.getpeereid
20
+ pid = if RUBY_PLATFORM.include?("darwin")
21
+ socket.getsockopt(0, 2).int # SOL_LOCAL / LOCAL_PEERPID
22
+ elsif Socket.const_defined?(:SO_PEERCRED)
23
+ observed_pid, observed_uid, observed_gid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
24
+ unavailable! unless [observed_uid, observed_gid] == [uid, gid]
25
+ observed_pid
26
+ end
27
+ value = identity.capture(pid)
28
+ unavailable! unless value && value["uid"] == uid && socket.getpeereid == [uid, gid] && identity.capture(pid) == value
29
+ value.merge("gid" => gid).freeze
30
+ rescue SystemCallError, IOError, SocketError, NotImplementedError
31
+ unavailable!
32
+ end
33
+
34
+ def live!(peer, deadline: default_deadline)
35
+ unavailable! unless peer.is_a?(Hash) && identity_observer(deadline).capture(peer["pid"]) == peer.slice("pid", "uid", "started_at", "host")
36
+ peer
37
+ end
38
+
39
+ private
40
+
41
+ def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
42
+
43
+ def identity_observer(deadline)
44
+ @identity || Ace::Runtime::Molecules::ProcessIdentity.new(
45
+ executor: Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline))
46
+ end
47
+
48
+ def unavailable!
49
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary kernel peer is unavailable or changed"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end