ace-assign 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/config.yml +13 -0
- data/CHANGELOG.md +18 -0
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +32 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
- data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
- data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
- data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
- data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
- data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
- data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
- data/lib/ace/assign/cli/commands/status.rb +24 -1
- data/lib/ace/assign/cli.rb +30 -2
- data/lib/ace/assign/models/assignment.rb +21 -4
- data/lib/ace/assign/models/attempt.rb +178 -0
- data/lib/ace/assign/models/attempt_binding.rb +116 -0
- data/lib/ace/assign/models/evidence_event.rb +82 -0
- data/lib/ace/assign/models/execution_receipt.rb +135 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
- data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
- data/lib/ace/assign/molecules/attempt_store.rb +201 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
- data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
- data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +56 -0
- metadata +20 -2
|
@@ -88,7 +88,10 @@ module Ace
|
|
|
88
88
|
preset_name = Ace::Assign::Molecules::PresetInferrer.infer_from_assignment(assignment)
|
|
89
89
|
auto_merge = true if preset_name && preset_name.start_with?("work-on-task-auto-merge")
|
|
90
90
|
|
|
91
|
-
evidence = Ace::Assign::Molecules::EvidenceCalculator.calculate(
|
|
91
|
+
evidence = Ace::Assign::Molecules::EvidenceCalculator.calculate(
|
|
92
|
+
auto_merge: auto_merge,
|
|
93
|
+
assignment_id: assignment.id
|
|
94
|
+
)
|
|
92
95
|
|
|
93
96
|
payload = {
|
|
94
97
|
assignment: {
|
|
@@ -132,6 +135,10 @@ module Ace
|
|
|
132
135
|
lines = []
|
|
133
136
|
lines.concat(compact_summary_lines(view.assignment, view.scoped_state, view.active_steps, view.next_step))
|
|
134
137
|
|
|
138
|
+
if (attempt_line = compact_attempt_line(view.assignment.id))
|
|
139
|
+
lines.insert(1, attempt_line)
|
|
140
|
+
end
|
|
141
|
+
|
|
135
142
|
unless target.assignment_id
|
|
136
143
|
other_line = compact_other_assignments_line(view.assignment.id, include_completed: include_completed)
|
|
137
144
|
lines << other_line if other_line
|
|
@@ -140,6 +147,22 @@ module Ace
|
|
|
140
147
|
puts lines.take(10).join("\n")
|
|
141
148
|
end
|
|
142
149
|
|
|
150
|
+
# Active attempt summary projected from the coordinator authority.
|
|
151
|
+
def compact_attempt_line(assignment_id)
|
|
152
|
+
projection = Organisms::AttemptCoordinator.new.status(assignment_id)
|
|
153
|
+
return nil unless projection
|
|
154
|
+
|
|
155
|
+
base = projection["base_head"].to_s[0, 7]
|
|
156
|
+
candidate = projection["candidate_head"].to_s[0, 7]
|
|
157
|
+
line = "Attempt: #{projection['attempt_id']} #{projection['state']}"
|
|
158
|
+
line += " | base:#{base}" unless base.to_s.empty?
|
|
159
|
+
line += " | cand:#{candidate.empty? ? 'none' : candidate}"
|
|
160
|
+
line += " | unresolved: #{projection['unresolved_effects'].join(',')}" if projection["unresolved_effects"].any?
|
|
161
|
+
line
|
|
162
|
+
rescue Ace::Assign::Error, Ace::Support::Cli::Error
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
143
166
|
def compact_summary_lines(assignment, state, active_steps, next_step)
|
|
144
167
|
lines = [
|
|
145
168
|
compact_assignment_line(assignment, state, active_steps, next_step),
|
data/lib/ace/assign/cli.rb
CHANGED
|
@@ -9,6 +9,10 @@ require_relative "models/assignment"
|
|
|
9
9
|
require_relative "models/step"
|
|
10
10
|
require_relative "models/queue_state"
|
|
11
11
|
require_relative "models/assignment_info"
|
|
12
|
+
require_relative "models/attempt_binding"
|
|
13
|
+
require_relative "models/attempt"
|
|
14
|
+
require_relative "models/execution_receipt"
|
|
15
|
+
require_relative "models/evidence_event"
|
|
12
16
|
|
|
13
17
|
# Atoms
|
|
14
18
|
require_relative "atoms/step_numbering"
|
|
@@ -22,9 +26,13 @@ require_relative "atoms/catalog_loader"
|
|
|
22
26
|
require_relative "atoms/composition_rules"
|
|
23
27
|
require_relative "atoms/assign_frontmatter_parser"
|
|
24
28
|
require_relative "atoms/tree_formatter"
|
|
29
|
+
require_relative "atoms/attempt_state_machine"
|
|
30
|
+
require_relative "atoms/evidence_digest"
|
|
31
|
+
require_relative "atoms/assignment_scope"
|
|
25
32
|
|
|
26
33
|
# Molecules
|
|
27
34
|
require_relative "molecules/assignment_manager"
|
|
35
|
+
require_relative "molecules/attempt_store"
|
|
28
36
|
require_relative "molecules/assignment_discoverer"
|
|
29
37
|
require_relative "molecules/queue_scanner"
|
|
30
38
|
require_relative "molecules/step_writer"
|
|
@@ -34,9 +42,14 @@ require_relative "molecules/fork_session_launcher"
|
|
|
34
42
|
require_relative "molecules/tmux_control_surface_runner"
|
|
35
43
|
require_relative "molecules/preset_inferrer"
|
|
36
44
|
require_relative "molecules/evidence_calculator"
|
|
45
|
+
require_relative "molecules/evidence_journal"
|
|
46
|
+
require_relative "molecules/execution_identity_resolver"
|
|
47
|
+
require_relative "molecules/receipt_verifier"
|
|
48
|
+
require_relative "molecules/attempt_reconciler"
|
|
37
49
|
|
|
38
50
|
# Organisms
|
|
39
51
|
require_relative "organisms/assignment_executor"
|
|
52
|
+
require_relative "organisms/attempt_coordinator"
|
|
40
53
|
|
|
41
54
|
# Commands
|
|
42
55
|
require_relative "cli/commands/create"
|
|
@@ -52,6 +65,11 @@ require_relative "cli/commands/list"
|
|
|
52
65
|
require_relative "cli/commands/select"
|
|
53
66
|
require_relative "cli/commands/fork_run"
|
|
54
67
|
require_relative "cli/commands/fork_session"
|
|
68
|
+
require_relative "cli/commands/attempt/base"
|
|
69
|
+
require_relative "cli/commands/attempt/start"
|
|
70
|
+
require_relative "cli/commands/attempt/status"
|
|
71
|
+
require_relative "cli/commands/attempt/finish"
|
|
72
|
+
require_relative "cli/commands/attempt/reconcile"
|
|
55
73
|
|
|
56
74
|
module Ace
|
|
57
75
|
module Assign
|
|
@@ -73,7 +91,11 @@ module Ace
|
|
|
73
91
|
["retry", "Retry failed step"],
|
|
74
92
|
["list", "List all assignments"],
|
|
75
93
|
["select", "Select active assignment"],
|
|
76
|
-
["fork-run", "Run subtree in forked process"]
|
|
94
|
+
["fork-run", "Run subtree in forked process"],
|
|
95
|
+
["attempt start", "Start a scoped attempt for an assignment step"],
|
|
96
|
+
["attempt status", "Show attempt status for an assignment"],
|
|
97
|
+
["attempt finish", "Finish an attempt with a structured execution receipt"],
|
|
98
|
+
["attempt reconcile", "Reconcile an interrupted or uncertain attempt"]
|
|
77
99
|
].freeze
|
|
78
100
|
|
|
79
101
|
HELP_EXAMPLES = [
|
|
@@ -83,7 +105,9 @@ module Ace
|
|
|
83
105
|
"ace-assign start # Start next workable step",
|
|
84
106
|
"ace-assign finish --message done.md # Complete active step",
|
|
85
107
|
"cat report.md | ace-assign finish # Complete step via stdin",
|
|
86
|
-
"ace-assign fork-run 010.01 # Run subtree in subprocess"
|
|
108
|
+
"ace-assign fork-run 010.01 # Run subtree in subprocess",
|
|
109
|
+
"ace-assign attempt start --assignment ID --step 010 --project ID",
|
|
110
|
+
"ace-assign attempt finish --attempt ID --receipt receipt.json"
|
|
87
111
|
].freeze
|
|
88
112
|
|
|
89
113
|
# Captured command exit code from last run
|
|
@@ -131,6 +155,10 @@ module Ace
|
|
|
131
155
|
register "select", wrap_command(Commands::Select)
|
|
132
156
|
register "fork-run", wrap_command(Commands::ForkRun)
|
|
133
157
|
register "fork-session", wrap_command(Commands::ForkSession)
|
|
158
|
+
register "attempt start", wrap_command(Commands::Attempt::Start)
|
|
159
|
+
register "attempt status", wrap_command(Commands::Attempt::Status)
|
|
160
|
+
register "attempt finish", wrap_command(Commands::Attempt::Finish)
|
|
161
|
+
register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
|
|
134
162
|
|
|
135
163
|
# Register version command
|
|
136
164
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
@@ -19,7 +19,8 @@ module Ace
|
|
|
19
19
|
# NOTE: Could be refactored to use Data.define in Ruby 3.2+ for reduced boilerplate,
|
|
20
20
|
# but tests would need to be updated to provide all required fields.
|
|
21
21
|
class Assignment
|
|
22
|
-
attr_reader :id, :name, :description, :created_at, :updated_at, :source_config, :cache_dir, :parent
|
|
22
|
+
attr_reader :id, :name, :description, :created_at, :updated_at, :source_config, :cache_dir, :parent,
|
|
23
|
+
:task_id, :project_id
|
|
23
24
|
|
|
24
25
|
# @param id [String] Unique assignment ID (6-char compact timestamp)
|
|
25
26
|
# @param name [String] Human-readable assignment name
|
|
@@ -29,7 +30,10 @@ module Ace
|
|
|
29
30
|
# @param source_config [String] Path to source configuration file
|
|
30
31
|
# @param cache_dir [String, nil] Assignment cache directory
|
|
31
32
|
# @param parent [String, nil] Parent assignment ID for hierarchy linking
|
|
32
|
-
|
|
33
|
+
# @param task_id [String, nil] Source task ID attached to this assignment (nil = taskless)
|
|
34
|
+
# @param project_id [String, nil] Project ID this assignment executes in
|
|
35
|
+
def initialize(id:, name:, created_at:, source_config:, description: nil, updated_at: nil, cache_dir: nil,
|
|
36
|
+
parent: nil, task_id: nil, project_id: nil)
|
|
33
37
|
@id = id.freeze
|
|
34
38
|
@name = name.freeze
|
|
35
39
|
@description = description&.freeze
|
|
@@ -38,6 +42,15 @@ module Ace
|
|
|
38
42
|
@source_config = source_config.freeze
|
|
39
43
|
@cache_dir = cache_dir&.freeze
|
|
40
44
|
@parent = parent&.freeze
|
|
45
|
+
@task_id = task_id&.freeze
|
|
46
|
+
@project_id = project_id&.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] True when a source task is attached; taskless
|
|
50
|
+
# assignments persist attempts locally only and never claim
|
|
51
|
+
# Git-backed recovery
|
|
52
|
+
def managed?
|
|
53
|
+
!task_id.nil? && !task_id.to_s.strip.empty?
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
# Convert to hash for YAML serialization
|
|
@@ -50,7 +63,9 @@ module Ace
|
|
|
50
63
|
"created_at" => created_at.iso8601,
|
|
51
64
|
"updated_at" => updated_at.iso8601,
|
|
52
65
|
"source_config" => source_config,
|
|
53
|
-
"parent" => parent
|
|
66
|
+
"parent" => parent,
|
|
67
|
+
"task_id" => task_id,
|
|
68
|
+
"project_id" => project_id
|
|
54
69
|
}.compact
|
|
55
70
|
end
|
|
56
71
|
|
|
@@ -67,7 +82,9 @@ module Ace
|
|
|
67
82
|
updated_at: parse_time(data["updated_at"]),
|
|
68
83
|
source_config: data["source_config"],
|
|
69
84
|
cache_dir: cache_dir,
|
|
70
|
-
parent: data["parent"]
|
|
85
|
+
parent: data["parent"],
|
|
86
|
+
task_id: data["task_id"],
|
|
87
|
+
project_id: data["project_id"]
|
|
71
88
|
)
|
|
72
89
|
end
|
|
73
90
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/attempt_state_machine"
|
|
4
|
+
require_relative "attempt_binding"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Models
|
|
9
|
+
# Mutable derived state of an assignment attempt.
|
|
10
|
+
#
|
|
11
|
+
# The binding is immutable; an Attempt carries the derived execution
|
|
12
|
+
# state: the current state-machine state, the pinned candidate head,
|
|
13
|
+
# the latest evidence journal commit, accepted receipts (by digest),
|
|
14
|
+
# recorded effects, and the append-only local event trail for
|
|
15
|
+
# taskless attempts. `base_head` (binding) and `candidate_head`
|
|
16
|
+
# (deliverable) are always tracked separately.
|
|
17
|
+
class Attempt
|
|
18
|
+
attr_reader :binding, :state, :candidate_head, :journal_commit,
|
|
19
|
+
:accepted_receipts, :effects, :events, :updated_at
|
|
20
|
+
|
|
21
|
+
# @param binding [Models::AttemptBinding] Immutable start-time binding
|
|
22
|
+
# @param state [String] State-machine state
|
|
23
|
+
# @param candidate_head [String, nil] Pinned deliverable head
|
|
24
|
+
# @param journal_commit [String, nil] Latest evidence journal commit SHA
|
|
25
|
+
# @param accepted_receipts [Array<Hash>] Accepted receipt payloads (digests included)
|
|
26
|
+
# @param effects [Array<Hash>] Recorded effects with intent/receipt linkage
|
|
27
|
+
# @param events [Array<Hash>] Local event trail (taskless attempts only)
|
|
28
|
+
# @param updated_at [Time] Last state change
|
|
29
|
+
def initialize(binding:, state: "reserved", candidate_head: nil, journal_commit: nil,
|
|
30
|
+
accepted_receipts: [], effects: [], events: [], updated_at: nil)
|
|
31
|
+
@binding = binding
|
|
32
|
+
@state = state.to_s
|
|
33
|
+
@candidate_head = candidate_head
|
|
34
|
+
@journal_commit = journal_commit
|
|
35
|
+
@accepted_receipts = accepted_receipts.dup.freeze
|
|
36
|
+
@effects = effects.dup.freeze
|
|
37
|
+
@events = events.dup.freeze
|
|
38
|
+
@updated_at = updated_at || binding.created_at
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String] Immutable attempt ID
|
|
42
|
+
def attempt_id
|
|
43
|
+
binding.attempt_id
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] True if no further transitions or effects are allowed
|
|
47
|
+
def terminal?
|
|
48
|
+
Atoms::AttemptStateMachine.terminal?(state)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] True if the attempt awaits reconciliation
|
|
52
|
+
def uncertain?
|
|
53
|
+
state == "uncertain"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] True if the attempt owns an active subtree slot
|
|
57
|
+
def active?
|
|
58
|
+
%w[reserved running uncertain].include?(state)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] "git" or "local_only" recovery mode
|
|
62
|
+
def recovery_mode
|
|
63
|
+
binding.recovery_mode
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] True when managed evidence-ref journaling applies
|
|
67
|
+
def managed?
|
|
68
|
+
binding.managed?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Effects whose intent was recorded but whose accepted receipt is missing.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Hash>] Unresolved effect records
|
|
74
|
+
def unresolved_effects
|
|
75
|
+
effects.reject { |effect| effect["receipt_digest"] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return a new Attempt with the state transitioned.
|
|
79
|
+
#
|
|
80
|
+
# @param to [String] Target state (validated against the state machine)
|
|
81
|
+
# @param at [Time] Transition time
|
|
82
|
+
# @return [Attempt] New attempt instance
|
|
83
|
+
# @raise [Ace::Assign::AttemptErrors::InvalidTransition] if illegal
|
|
84
|
+
def transition(to, at: Time.now.utc)
|
|
85
|
+
Atoms::AttemptStateMachine.transition!(state, to)
|
|
86
|
+
with(state: to.to_s, updated_at: at)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return a new Attempt with updated derived fields. The binding is
|
|
90
|
+
# never replaced.
|
|
91
|
+
#
|
|
92
|
+
# @param attrs [Hash] Fields to override (state, candidate_head,
|
|
93
|
+
# journal_commit, accepted_receipts, effects, events, updated_at)
|
|
94
|
+
# @return [Attempt] New attempt instance
|
|
95
|
+
def with(**attrs)
|
|
96
|
+
Attempt.new(
|
|
97
|
+
binding: binding,
|
|
98
|
+
state: attrs.fetch(:state, state),
|
|
99
|
+
candidate_head: attrs.fetch(:candidate_head, candidate_head),
|
|
100
|
+
journal_commit: attrs.fetch(:journal_commit, journal_commit),
|
|
101
|
+
accepted_receipts: attrs.fetch(:accepted_receipts, accepted_receipts),
|
|
102
|
+
effects: attrs.fetch(:effects, effects),
|
|
103
|
+
events: attrs.fetch(:events, events),
|
|
104
|
+
updated_at: attrs.fetch(:updated_at, Time.now.utc)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Convert to a hash for canonical serialization.
|
|
109
|
+
# @return [Hash] Attempt data with string keys
|
|
110
|
+
def to_h
|
|
111
|
+
{
|
|
112
|
+
"binding" => binding.to_h,
|
|
113
|
+
"state" => state,
|
|
114
|
+
"candidate_head" => candidate_head,
|
|
115
|
+
"journal_commit" => journal_commit,
|
|
116
|
+
"accepted_receipts" => accepted_receipts,
|
|
117
|
+
"effects" => effects,
|
|
118
|
+
"events" => events,
|
|
119
|
+
"updated_at" => updated_at.iso8601
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# JSON projection exposed by public commands. Contains attempt
|
|
124
|
+
# ID, state, binding facts, references and digests only — never
|
|
125
|
+
# credentials, receipt bytes, or terminal output.
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash] Redacted projection
|
|
128
|
+
def projection
|
|
129
|
+
{
|
|
130
|
+
"attempt_id" => attempt_id,
|
|
131
|
+
"state" => state,
|
|
132
|
+
"assignment_id" => binding.assignment_id,
|
|
133
|
+
"scope" => binding.scope,
|
|
134
|
+
"project_id" => binding.project_id,
|
|
135
|
+
"task_id" => binding.task_id,
|
|
136
|
+
"actor" => binding.actor,
|
|
137
|
+
"role" => binding.role,
|
|
138
|
+
"runtime" => binding.runtime,
|
|
139
|
+
"base_head" => binding.base_head,
|
|
140
|
+
"candidate_head" => candidate_head,
|
|
141
|
+
"evidence_git_ref" => binding.evidence_git_ref,
|
|
142
|
+
"journal_commit" => journal_commit,
|
|
143
|
+
"recovery_mode" => recovery_mode,
|
|
144
|
+
"accepted_receipt_digests" => accepted_receipts.map { |receipt| receipt["digest"] },
|
|
145
|
+
"unresolved_effects" => unresolved_effects.map { |effect| effect["operation"] },
|
|
146
|
+
"created_at" => binding.created_at.iso8601,
|
|
147
|
+
"updated_at" => updated_at.iso8601
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Rebuild an attempt from serialized data.
|
|
152
|
+
#
|
|
153
|
+
# @param data [Hash] Serialized attempt (string keys)
|
|
154
|
+
# @return [Attempt] Rebuilt attempt
|
|
155
|
+
def self.from_h(data)
|
|
156
|
+
new(
|
|
157
|
+
binding: AttemptBinding.from_h(data["binding"]),
|
|
158
|
+
state: data["state"],
|
|
159
|
+
candidate_head: data["candidate_head"],
|
|
160
|
+
journal_commit: data["journal_commit"],
|
|
161
|
+
accepted_receipts: data["accepted_receipts"] || [],
|
|
162
|
+
effects: data["effects"] || [],
|
|
163
|
+
events: data["events"] || [],
|
|
164
|
+
updated_at: parse_time(data["updated_at"])
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.parse_time(value)
|
|
169
|
+
return nil if value.nil?
|
|
170
|
+
return value if value.is_a?(Time)
|
|
171
|
+
require "time"
|
|
172
|
+
Time.parse(value)
|
|
173
|
+
end
|
|
174
|
+
private_class_method :parse_time
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Models
|
|
6
|
+
# Immutable start-time facts for an assignment attempt.
|
|
7
|
+
#
|
|
8
|
+
# A binding freezes exactly what an attempt owns: the immutable attempt
|
|
9
|
+
# ID, the assignment and step/subtree scope, the project, the
|
|
10
|
+
# authenticated actor/role and runtime identity resolved at the
|
|
11
|
+
# execution boundary, the source task ID, the base head captured once at
|
|
12
|
+
# start, and the configured evidence ref. Bindings are never mutated;
|
|
13
|
+
# changes require a new attempt.
|
|
14
|
+
class AttemptBinding
|
|
15
|
+
attr_reader :attempt_id, :assignment_id, :scope, :project_id, :task_id,
|
|
16
|
+
:actor, :role, :runtime, :base_head, :evidence_git_ref, :created_at
|
|
17
|
+
|
|
18
|
+
# @param attempt_id [String] Immutable attempt ID (ace-b36ts)
|
|
19
|
+
# @param assignment_id [String] Owning assignment ID
|
|
20
|
+
# @param scope [String] Canonical step/subtree scope
|
|
21
|
+
# @param project_id [String] Project the attempt executes in
|
|
22
|
+
# @param actor [String] Authenticated actor from the execution boundary
|
|
23
|
+
# @param role [String] Trusted role (coordinator, service, worker)
|
|
24
|
+
# @param runtime [String] Runtime identity of the executing boundary
|
|
25
|
+
# @param base_head [String] Git HEAD captured exactly once at start
|
|
26
|
+
# @param task_id [String, nil] Source task ID (nil for taskless assignments)
|
|
27
|
+
# @param evidence_git_ref [String, nil] Evidence ref for managed attempts
|
|
28
|
+
# @param created_at [Time] Binding creation time
|
|
29
|
+
def initialize(attempt_id:, assignment_id:, scope:, project_id:, actor:, role:, runtime:, base_head:,
|
|
30
|
+
task_id: nil, evidence_git_ref: nil, created_at:)
|
|
31
|
+
@attempt_id = attempt_id.freeze
|
|
32
|
+
@assignment_id = assignment_id.freeze
|
|
33
|
+
@scope = scope.freeze
|
|
34
|
+
@project_id = project_id.freeze
|
|
35
|
+
@task_id = task_id&.freeze
|
|
36
|
+
@actor = actor.freeze
|
|
37
|
+
@role = role.freeze
|
|
38
|
+
@runtime = runtime.freeze
|
|
39
|
+
@base_head = base_head.freeze
|
|
40
|
+
@evidence_git_ref = evidence_git_ref&.freeze
|
|
41
|
+
@created_at = created_at
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] True when the attempt is bound to a source task and
|
|
45
|
+
# eligible for managed evidence-ref delivery
|
|
46
|
+
def managed?
|
|
47
|
+
!task_id.nil? && !task_id.to_s.strip.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Recovery mode implied by task attachment: git-backed journaling for
|
|
51
|
+
# task-attached attempts, disposable local state otherwise.
|
|
52
|
+
#
|
|
53
|
+
# @return [String] "git" or "local_only"
|
|
54
|
+
def recovery_mode
|
|
55
|
+
managed? ? "git" : "local_only"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Identity facts repeated by receipts must match the binding exactly.
|
|
59
|
+
#
|
|
60
|
+
# @param other [AttemptBinding] Binding to compare
|
|
61
|
+
# @return [Boolean] True if both bindings own the same attempt identity
|
|
62
|
+
def same_identity?(other)
|
|
63
|
+
attempt_id == other.attempt_id &&
|
|
64
|
+
assignment_id == other.assignment_id &&
|
|
65
|
+
scope == other.scope &&
|
|
66
|
+
project_id == other.project_id
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Convert to a hash for canonical serialization.
|
|
70
|
+
# @return [Hash] Binding data with string keys
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
"attempt_id" => attempt_id,
|
|
74
|
+
"assignment_id" => assignment_id,
|
|
75
|
+
"scope" => scope,
|
|
76
|
+
"project_id" => project_id,
|
|
77
|
+
"task_id" => task_id,
|
|
78
|
+
"actor" => actor,
|
|
79
|
+
"role" => role,
|
|
80
|
+
"runtime" => runtime,
|
|
81
|
+
"base_head" => base_head,
|
|
82
|
+
"evidence_git_ref" => evidence_git_ref,
|
|
83
|
+
"created_at" => created_at.iso8601
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rebuild a binding from serialized data.
|
|
88
|
+
#
|
|
89
|
+
# @param data [Hash] Serialized binding (string keys)
|
|
90
|
+
# @return [AttemptBinding] Rebuilt binding
|
|
91
|
+
def self.from_h(data)
|
|
92
|
+
new(
|
|
93
|
+
attempt_id: data["attempt_id"],
|
|
94
|
+
assignment_id: data["assignment_id"],
|
|
95
|
+
scope: data["scope"],
|
|
96
|
+
project_id: data["project_id"],
|
|
97
|
+
task_id: data["task_id"],
|
|
98
|
+
actor: data["actor"],
|
|
99
|
+
role: data["role"],
|
|
100
|
+
runtime: data["runtime"],
|
|
101
|
+
base_head: data["base_head"],
|
|
102
|
+
evidence_git_ref: data["evidence_git_ref"],
|
|
103
|
+
created_at: parse_time(data["created_at"])
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.parse_time(value)
|
|
108
|
+
return value if value.is_a?(Time)
|
|
109
|
+
require "time"
|
|
110
|
+
Time.parse(value)
|
|
111
|
+
end
|
|
112
|
+
private_class_method :parse_time
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/evidence_digest"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Models
|
|
8
|
+
# Append-only accepted evidence event.
|
|
9
|
+
#
|
|
10
|
+
# Evidence events are the journal trail of an attempt: intent,
|
|
11
|
+
# process start, accepted receipts, state transitions, candidate
|
|
12
|
+
# invalidations, and reconciliations. Each event chains to the digest
|
|
13
|
+
# of its predecessor for the attempt, so accepted history cannot be
|
|
14
|
+
# reordered or silently rewritten. Events carry bounded, non-secret
|
|
15
|
+
# metadata only.
|
|
16
|
+
module EvidenceEvent
|
|
17
|
+
TYPES = %w[
|
|
18
|
+
intent process_start receipt_accepted transition
|
|
19
|
+
candidate_invalidated reconciliation
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Build an event chained to a previous digest.
|
|
23
|
+
#
|
|
24
|
+
# @param type [String] Event type (see TYPES)
|
|
25
|
+
# @param attempt_id [String] Owning attempt ID
|
|
26
|
+
# @param payload [Hash] Bounded, non-secret event metadata
|
|
27
|
+
# @param previous_digest [String, nil] Digest of the previous event (chain)
|
|
28
|
+
# @param recorded_at [Time] Event creation time
|
|
29
|
+
# @return [Hash] Serializable event with type, payload, digests, timestamp
|
|
30
|
+
# @raise [ArgumentError] for unknown event types
|
|
31
|
+
def self.build(type:, attempt_id:, payload:, previous_digest: nil, recorded_at: Time.now.utc)
|
|
32
|
+
raise ArgumentError, "Unknown evidence event type: #{type}" unless TYPES.include?(type.to_s)
|
|
33
|
+
|
|
34
|
+
recorded_at = recorded_at.utc
|
|
35
|
+
body = {
|
|
36
|
+
"type" => type.to_s,
|
|
37
|
+
"attempt_id" => attempt_id,
|
|
38
|
+
"payload" => payload,
|
|
39
|
+
"previous_digest" => previous_digest,
|
|
40
|
+
"recorded_at" => recorded_at.iso8601
|
|
41
|
+
}
|
|
42
|
+
body.merge("digest" => Atoms::EvidenceDigest.digest(body))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validate that a stored event is intact: its digest must match a
|
|
46
|
+
# recomputation over its own body.
|
|
47
|
+
#
|
|
48
|
+
# @param event [Hash] Stored event
|
|
49
|
+
# @return [Boolean] True if the event digest verifies
|
|
50
|
+
def self.valid?(event)
|
|
51
|
+
stored = event["digest"]
|
|
52
|
+
return false if stored.nil?
|
|
53
|
+
|
|
54
|
+
body = {
|
|
55
|
+
"type" => event["type"],
|
|
56
|
+
"attempt_id" => event["attempt_id"],
|
|
57
|
+
"payload" => event["payload"],
|
|
58
|
+
"previous_digest" => event["previous_digest"],
|
|
59
|
+
"recorded_at" => event["recorded_at"]
|
|
60
|
+
}
|
|
61
|
+
Atoms::EvidenceDigest.digest(body) == stored
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validate a whole chain: every event verifies and links to its
|
|
65
|
+
# predecessor.
|
|
66
|
+
#
|
|
67
|
+
# @param events [Array<Hash>] Ordered event list
|
|
68
|
+
# @return [Boolean] True if the chain is intact
|
|
69
|
+
def self.chain_valid?(events)
|
|
70
|
+
previous = nil
|
|
71
|
+
events.all? do |event|
|
|
72
|
+
return false unless valid?(event)
|
|
73
|
+
return false unless event["previous_digest"] == previous
|
|
74
|
+
|
|
75
|
+
previous = event["digest"]
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|