ace-assign 0.57.1 → 0.60.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +46 -12
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +49 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
  12. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  13. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  14. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  15. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  16. data/lib/ace/assign/cli/commands/status.rb +24 -1
  17. data/lib/ace/assign/cli.rb +34 -2
  18. data/lib/ace/assign/models/assignment.rb +21 -4
  19. data/lib/ace/assign/models/attempt.rb +178 -0
  20. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  21. data/lib/ace/assign/models/evidence_event.rb +82 -0
  22. data/lib/ace/assign/models/execution_receipt.rb +139 -0
  23. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  24. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  25. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  26. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  27. data/lib/ace/assign/molecules/evidence_journal.rb +423 -0
  28. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  29. data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
  30. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
  31. data/lib/ace/assign/molecules/receipt_verifier.rb +272 -0
  32. data/lib/ace/assign/organisms/assignment_executor.rb +12 -4
  33. data/lib/ace/assign/organisms/attempt_coordinator.rb +618 -0
  34. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +56 -0
  37. metadata +36 -2
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Classify interruptions and resolve uncertainty against verified,
12
+ # boundary-attributed receipts. Never replays external effects.
13
+ class Reconcile < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include Attempt::Base
16
+
17
+ desc "Reconcile an interrupted or uncertain attempt"
18
+
19
+ option :attempt, desc: "Attempt ID"
20
+ option :receipt, desc: "Path to the receipt JSON file (required to resolve uncertainty)"
21
+
22
+ def call(**options)
23
+ attempt_id = require_option(options, :attempt, "reconcile --attempt ID [--receipt FILE]")
24
+ receipt = options[:receipt].to_s.strip
25
+
26
+ result = build_coordinator.reconcile(
27
+ attempt_id: attempt_id,
28
+ receipt_path: receipt.empty? ? nil : receipt
29
+ )
30
+
31
+ emit_json(result.projection)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Bind an immutable attempt to an assignment step/subtree.
12
+ #
13
+ # Identity is derived from the execution boundary; a repeated
14
+ # identical start returns the active attempt, and a conflicting
15
+ # binding fails without launching a second writer.
16
+ class Start < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+ include Attempt::Base
19
+
20
+ desc "Start a scoped attempt for an assignment step"
21
+
22
+ option :assignment, desc: "Assignment ID"
23
+ option :step, desc: "Step or subtree scope (e.g. 010 or 010.01)"
24
+ option :project, desc: "Project ID the attempt executes in"
25
+
26
+ def call(**options)
27
+ usage = "start --assignment ID --step STEP --project ID"
28
+ assignment = require_option(options, :assignment, usage)
29
+ step = require_option(options, :step, usage)
30
+ project = require_option(options, :project, usage)
31
+
32
+ attempt = build_coordinator.start(
33
+ assignment_id: assignment,
34
+ step: step,
35
+ project_id: project
36
+ )
37
+
38
+ emit_json(attempt.projection)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module Ace
7
+ module Assign
8
+ module CLI
9
+ module Commands
10
+ module Attempt
11
+ # Show attempt state, binding facts, and evidence references.
12
+ # JSON output carries only state, binding, references, and digests
13
+ # — never credentials or receipt bytes.
14
+ class Status < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+ include Attempt::Base
17
+
18
+ desc "Show attempt status for an assignment"
19
+
20
+ option :assignment, desc: "Assignment ID"
21
+ option :format, desc: "Output format (json, text)", default: "json"
22
+
23
+ def call(**options)
24
+ assignment = require_option(options, :assignment, "status --assignment ID --format json")
25
+ projection = build_coordinator.status(assignment)
26
+
27
+ if options[:format] == "text"
28
+ print_text(assignment, projection)
29
+ else
30
+ emit_json(projection || {"attempt" => nil})
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def print_text(assignment, projection)
37
+ return puts "No attempts recorded for #{assignment}" if projection.nil?
38
+
39
+ puts "Attempt: #{projection['attempt_id']} #{projection['state']} (#{projection['scope']})"
40
+ puts "Recovery: #{projection['recovery_mode']}"
41
+ puts "Base head: #{projection['base_head']}"
42
+ puts "Candidate head: #{projection['candidate_head'] || 'unpinned'}"
43
+ puts "Evidence ref: #{projection['evidence_git_ref'] || 'local-only'}"
44
+ puts "Journal commit: #{projection['journal_commit'] || 'none'}"
45
+ return unless projection["unresolved_effects"].any?
46
+
47
+ puts "Unresolved effects: #{projection['unresolved_effects'].join(', ')}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -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(auto_merge: auto_merge)
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),
@@ -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,14 @@ 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
34
+ require_relative "molecules/lifecycle_exclusion"
27
35
  require_relative "molecules/assignment_manager"
36
+ require_relative "molecules/attempt_store"
28
37
  require_relative "molecules/assignment_discoverer"
29
38
  require_relative "molecules/queue_scanner"
30
39
  require_relative "molecules/step_writer"
@@ -34,9 +43,14 @@ require_relative "molecules/fork_session_launcher"
34
43
  require_relative "molecules/tmux_control_surface_runner"
35
44
  require_relative "molecules/preset_inferrer"
36
45
  require_relative "molecules/evidence_calculator"
46
+ require_relative "molecules/evidence_journal"
47
+ require_relative "molecules/execution_identity_resolver"
48
+ require_relative "molecules/receipt_verifier"
49
+ require_relative "molecules/attempt_reconciler"
37
50
 
38
51
  # Organisms
39
52
  require_relative "organisms/assignment_executor"
53
+ require_relative "organisms/attempt_coordinator"
40
54
 
41
55
  # Commands
42
56
  require_relative "cli/commands/create"
@@ -52,6 +66,12 @@ require_relative "cli/commands/list"
52
66
  require_relative "cli/commands/select"
53
67
  require_relative "cli/commands/fork_run"
54
68
  require_relative "cli/commands/fork_session"
69
+ require_relative "cli/commands/attempt/base"
70
+ require_relative "cli/commands/attempt/start"
71
+ require_relative "cli/commands/attempt/status"
72
+ require_relative "cli/commands/attempt/finish"
73
+ require_relative "cli/commands/attempt/reconcile"
74
+ require_relative "cli/commands/attempt/evidence"
55
75
 
56
76
  module Ace
57
77
  module Assign
@@ -73,7 +93,12 @@ module Ace
73
93
  ["retry", "Retry failed step"],
74
94
  ["list", "List all assignments"],
75
95
  ["select", "Select active assignment"],
76
- ["fork-run", "Run subtree in forked process"]
96
+ ["fork-run", "Run subtree in forked process"],
97
+ ["attempt start", "Start a scoped attempt for an assignment step"],
98
+ ["attempt status", "Show attempt status for an assignment"],
99
+ ["attempt evidence", "Read accepted current-head check evidence"],
100
+ ["attempt finish", "Finish an attempt with a structured execution receipt"],
101
+ ["attempt reconcile", "Reconcile an interrupted or uncertain attempt"]
77
102
  ].freeze
78
103
 
79
104
  HELP_EXAMPLES = [
@@ -83,7 +108,9 @@ module Ace
83
108
  "ace-assign start # Start next workable step",
84
109
  "ace-assign finish --message done.md # Complete active step",
85
110
  "cat report.md | ace-assign finish # Complete step via stdin",
86
- "ace-assign fork-run 010.01 # Run subtree in subprocess"
111
+ "ace-assign fork-run 010.01 # Run subtree in subprocess",
112
+ "ace-assign attempt start --assignment ID --step 010 --project ID",
113
+ "ace-assign attempt finish --attempt ID --receipt receipt.json"
87
114
  ].freeze
88
115
 
89
116
  # Captured command exit code from last run
@@ -131,6 +158,11 @@ module Ace
131
158
  register "select", wrap_command(Commands::Select)
132
159
  register "fork-run", wrap_command(Commands::ForkRun)
133
160
  register "fork-session", wrap_command(Commands::ForkSession)
161
+ register "attempt start", wrap_command(Commands::Attempt::Start)
162
+ register "attempt status", wrap_command(Commands::Attempt::Status)
163
+ register "attempt evidence", wrap_command(Commands::Attempt::Evidence)
164
+ register "attempt finish", wrap_command(Commands::Attempt::Finish)
165
+ register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
134
166
 
135
167
  # Register version command
136
168
  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
- def initialize(id:, name:, created_at:, source_config:, description: nil, updated_at: nil, cache_dir: nil, parent: nil)
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