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,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
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/evidence_digest"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Models
8
+ # Structured, non-secret execution receipt.
9
+ #
10
+ # A receipt is the only accepted proof that an attempted operation
11
+ # completed: it names the attributed producer, the attempted operation,
12
+ # the actual tested/reviewed head, the scope, a verdict, the required
13
+ # artifacts with SHA-256 digests, and executed checks. Receipts never
14
+ # carry credentials, terminal bytes, or receipt-file payloads; journal
15
+ # entries keep metadata and digests only.
16
+ class ExecutionReceipt
17
+ VERDICTS = %w[succeeded failed].freeze
18
+
19
+ # Field names rejected outright in receipt payloads: accepted evidence
20
+ # is structured metadata, never raw process output or credentials.
21
+ FORBIDDEN_FIELDS = %w[
22
+ stdout stderr terminal_output log logs credentials secret secrets
23
+ token password api_key private_key env environment
24
+ ].freeze
25
+
26
+ attr_reader :attempt_id, :assignment_id, :project_id, :scope, :operation,
27
+ :producer, :head, :verdict, :artifacts, :checks, :review, :campaign, :recorded_at, :digest
28
+
29
+ # @param attempt_id [String] Attempt the receipt belongs to
30
+ # @param assignment_id [String] Owning assignment ID
31
+ # @param project_id [String] Project the operation ran in
32
+ # @param scope [String] Canonical step/subtree scope
33
+ # @param operation [String] Attempted operation name
34
+ # @param producer [Hash] Attributed producer (actor, role, runtime)
35
+ # @param head [String] Actual tested/reviewed head
36
+ # @param verdict [String] "succeeded" or "failed"
37
+ # @param artifacts [Array<Hash>] Artifacts with path + sha256
38
+ # @param checks [Array<Hash>] Executed checks with name + verdict
39
+ # @param review [Hash, nil] Reviewer verdict for review receipts
40
+ # @param recorded_at [Time] Receipt creation time
41
+ # @param digest [String, nil] Canonical digest (computed when nil)
42
+ def initialize(attempt_id:, assignment_id:, project_id:, scope:, operation:, producer:, head:, verdict:,
43
+ artifacts: [], checks: [], review: nil, campaign: nil, recorded_at:, digest: nil)
44
+ @attempt_id = attempt_id
45
+ @assignment_id = assignment_id
46
+ @project_id = project_id
47
+ @scope = scope
48
+ @operation = operation
49
+ @producer = producer
50
+ @head = head
51
+ @verdict = verdict
52
+ @artifacts = artifacts
53
+ @checks = checks
54
+ @review = review
55
+ @campaign = campaign
56
+ @recorded_at = recorded_at
57
+ @digest = digest || Atoms::EvidenceDigest.digest(digest_payload)
58
+ end
59
+
60
+ # Canonical payload covered by the receipt digest (excludes the digest
61
+ # itself and the record timestamp, which is assigned at acceptance).
62
+ #
63
+ # @return [Hash] Digest payload
64
+ def digest_payload
65
+ payload = {
66
+ "attempt_id" => attempt_id,
67
+ "assignment_id" => assignment_id,
68
+ "project_id" => project_id,
69
+ "scope" => scope,
70
+ "operation" => operation,
71
+ "producer" => producer,
72
+ "head" => head,
73
+ "verdict" => verdict,
74
+ "artifacts" => artifacts,
75
+ "checks" => checks,
76
+ "review" => review
77
+ }
78
+ payload["campaign"] = campaign if campaign
79
+ payload
80
+ end
81
+
82
+ # Convert to a hash for serialization.
83
+ # @return [Hash] Receipt data with string keys
84
+ def to_h
85
+ digest_payload.merge(
86
+ "recorded_at" => recorded_at.iso8601,
87
+ "digest" => digest
88
+ )
89
+ end
90
+
91
+ # Rebuild a receipt from serialized or submitted data.
92
+ #
93
+ # @param data [Hash] Receipt data (string keys); missing digest is computed
94
+ # @return [ExecutionReceipt] Receipt instance
95
+ def self.from_h(data)
96
+ new(
97
+ attempt_id: data["attempt_id"],
98
+ assignment_id: data["assignment_id"],
99
+ project_id: data["project_id"],
100
+ scope: data["scope"],
101
+ operation: data["operation"],
102
+ producer: data["producer"] || {},
103
+ head: data["head"],
104
+ verdict: data["verdict"],
105
+ artifacts: data["artifacts"] || [],
106
+ checks: data["checks"] || [],
107
+ review: data["review"],
108
+ campaign: data["campaign"],
109
+ recorded_at: parse_time(data["recorded_at"]) || Time.now.utc,
110
+ digest: data["digest"]
111
+ )
112
+ end
113
+
114
+ # Reject payloads that carry forbidden fields anywhere in the tree.
115
+ #
116
+ # @param data [Hash] Raw submitted receipt data
117
+ # @return [Boolean] True if a forbidden field is present
118
+ def self.forbidden_field?(data)
119
+ case data
120
+ when Hash
121
+ data.any? { |key, value| FORBIDDEN_FIELDS.include?(key.to_s) || forbidden_field?(value) }
122
+ when Array
123
+ data.any? { |item| forbidden_field?(item) }
124
+ else
125
+ false
126
+ end
127
+ end
128
+
129
+ def self.parse_time(value)
130
+ return nil if value.nil?
131
+ return value if value.is_a?(Time)
132
+ require "time"
133
+ Time.parse(value)
134
+ end
135
+ private_class_method :parse_time
136
+ end
137
+ end
138
+ end
139
+ end
@@ -23,8 +23,10 @@ module Ace
23
23
  # @param description [String, nil] Assignment description
24
24
  # @param source_config [String] Path to source config file
25
25
  # @param parent [String, nil] Parent assignment ID for hierarchy linking
26
+ # @param task_id [String, nil] Source task ID attachment (nil = taskless)
27
+ # @param project_id [String, nil] Project ID the assignment executes in
26
28
  # @return [Models::Assignment] Created assignment
27
- def create(name:, source_config:, description: nil, parent: nil)
29
+ def create(name:, source_config:, description: nil, parent: nil, task_id: nil, project_id: nil)
28
30
  # Ensure cache base directory exists before generate_assignment_id
29
31
  FileUtils.mkdir_p(@cache_base)
30
32
 
@@ -46,7 +48,9 @@ module Ace
46
48
  updated_at: now,
47
49
  source_config: source_config,
48
50
  cache_dir: cache_dir,
49
- parent: parent
51
+ parent: parent,
52
+ task_id: task_id,
53
+ project_id: project_id
50
54
  )
51
55
 
52
56
  # Write assignment.yaml
@@ -175,7 +179,9 @@ module Ace
175
179
  updated_at: Time.now.utc,
176
180
  source_config: assignment.source_config,
177
181
  cache_dir: assignment.cache_dir,
178
- parent: assignment.parent
182
+ parent: assignment.parent,
183
+ task_id: assignment.task_id,
184
+ project_id: assignment.project_id
179
185
  )
180
186
 
181
187
  write_assignment_file(updated)
@@ -186,6 +192,32 @@ module Ace
186
192
  updated
187
193
  end
188
194
 
195
+ # The attempt store sharing this manager's cache base. The store is
196
+ # the single authority for attempt records and subtree ownership;
197
+ # assignment metadata never carries attempt history.
198
+ #
199
+ # @return [Molecules::AttemptStore] Attempt store
200
+ def attempt_store
201
+ @attempt_store ||= Molecules::AttemptStore.new(cache_base: @cache_base)
202
+ end
203
+
204
+ # Assignment-scoped lookup of the active attempt for a scope.
205
+ #
206
+ # @param assignment_id [String] Assignment ID
207
+ # @param scope [String] Step/subtree scope
208
+ # @return [Models::Attempt, nil] Active attempt or nil
209
+ def active_attempt(assignment_id, scope)
210
+ attempt_store.active(assignment_id, scope)
211
+ end
212
+
213
+ # All attempts recorded for an assignment.
214
+ #
215
+ # @param assignment_id [String] Assignment ID
216
+ # @return [Array<Models::Attempt>] Persisted attempts
217
+ def attempts(assignment_id)
218
+ attempt_store.list(assignment_id)
219
+ end
220
+
189
221
  # List all assignments
190
222
  #
191
223
  # @return [Array<Models::Assignment>] All assignments
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Molecules
6
+ # Conservative classification and resolution of interrupted attempts.
7
+ #
8
+ # Classification of a `running` attempt found after a lost session:
9
+ # - no recorded process start -> `stopped` (nothing could have run)
10
+ # - recorded process still verifiably live -> keep `running`
11
+ # - otherwise -> `uncertain` (the operation may have completed without
12
+ # a durable receipt)
13
+ #
14
+ # Reconciliation resolves an `uncertain` attempt only against verified
15
+ # receipts whose producer runtime matches the recorded execution
16
+ # boundary. It never replays operations: merge, publish, and deploy
17
+ # effects are never retried automatically, and an uncertain attempt
18
+ # without a receipt stays uncertain.
19
+ class AttemptReconciler
20
+ # @param journal [EvidenceJournal, nil] Managed evidence journal
21
+ # @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
22
+ def initialize(journal: nil, verifier: nil)
23
+ @journal = journal
24
+ @verifier = verifier
25
+ end
26
+
27
+ # Classify a running attempt after an interruption.
28
+ #
29
+ # @param attempt [Models::Attempt] Running attempt
30
+ # @return [Symbol] :stopped, :live, or :uncertain
31
+ def classify(attempt)
32
+ return :live if process_live?(attempt)
33
+ return :stopped unless process_started?(attempt)
34
+
35
+ :uncertain
36
+ end
37
+
38
+ # @param attempt [Models::Attempt] Attempt to inspect
39
+ # @return [Boolean] True when a process start was recorded
40
+ def process_started?(attempt)
41
+ !start_event(attempt).nil?
42
+ end
43
+
44
+ # @param attempt [Models::Attempt] Attempt to inspect
45
+ # @return [Boolean] True when the recorded process is verifiably alive
46
+ def process_live?(attempt)
47
+ pid = start_event(attempt)&.dig("payload", "pid")
48
+ return false unless pid
49
+
50
+ Process.kill(0, pid)
51
+ true
52
+ rescue Errno::EPERM
53
+ true
54
+ rescue Errno::ESRCH, TypeError, ArgumentError
55
+ false
56
+ end
57
+
58
+ # Runtime recorded at the attempt's process start — the boundary any
59
+ # reconciling receipt must attribute to.
60
+ #
61
+ # @param attempt [Models::Attempt] Attempt to inspect
62
+ # @return [String, nil] Recorded runtime identity
63
+ def recorded_runtime(attempt)
64
+ start_event(attempt)&.dig("payload", "runtime")
65
+ end
66
+
67
+ private
68
+
69
+ def start_event(attempt)
70
+ events_for(attempt).reverse.find do |event|
71
+ event["type"] == "process_start" && event["attempt_id"] == attempt.attempt_id
72
+ end
73
+ end
74
+
75
+ def events_for(attempt)
76
+ events = if attempt.managed?
77
+ (@journal || Molecules::EvidenceJournal.new(repo_root: Dir.pwd))
78
+ .read_events(attempt.binding.assignment_id)
79
+ else
80
+ attempt.events
81
+ end
82
+ # Only this attempt's events define its execution boundary.
83
+ events.select { |event| event["attempt_id"] == attempt.attempt_id }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Durable local attempt state: the single authority for attempt records
10
+ # and active subtree ownership.
11
+ #
12
+ # Layout under the assignment cache directory (`.ace-local/assign` by
13
+ # default, which remains disposable session/projection data):
14
+ # - `<assignment-id>/attempts/records/<attempt-id>.json` — full attempt
15
+ # record, written atomically (temp file + rename).
16
+ # - `<assignment-id>/attempts/active/<ownership-key>.json` — pointer to
17
+ # the one active attempt owning an assignment + normalized subtree.
18
+ # - `<assignment-id>/attempts/.lock` — flock serialization for
19
+ # claim/release and record transitions.
20
+ #
21
+ # Only one active attempt owns a subtree; claim replaces the pointer
22
+ # only for the identical binding, and release clears it only when it
23
+ # still references the given attempt.
24
+ class AttemptStore
25
+ # @param cache_base [String] Base cache directory
26
+ def initialize(cache_base: nil)
27
+ @cache_base = cache_base || Ace::Assign.cache_dir
28
+ end
29
+
30
+ # @param assignment_id [String] Assignment ID
31
+ # @return [String] Attempts directory for the assignment
32
+ def attempts_dir(assignment_id)
33
+ File.join(@cache_base, assignment_id, "attempts")
34
+ end
35
+
36
+ # Serialize a block under the assignment's exclusive attempt lock.
37
+ #
38
+ # @param assignment_id [String] Assignment ID
39
+ # @yield Block executed while holding the lock
40
+ def with_lock(assignment_id)
41
+ FileUtils.mkdir_p(attempts_dir(assignment_id))
42
+ File.open(lock_path(assignment_id), File::RDWR | File::CREAT) do |lock|
43
+ lock.flock(File::LOCK_EX)
44
+ begin
45
+ yield
46
+ ensure
47
+ lock.flock(File::LOCK_UN)
48
+ end
49
+ end
50
+ end
51
+
52
+ # Persist an attempt record atomically.
53
+ #
54
+ # @param attempt [Models::Attempt] Attempt to persist
55
+ def save(attempt)
56
+ FileUtils.mkdir_p(records_dir(attempt.binding.assignment_id))
57
+ atomic_write(
58
+ record_path(attempt.binding.assignment_id, attempt.attempt_id),
59
+ JSON.pretty_generate(attempt.to_h)
60
+ )
61
+ end
62
+
63
+ # Load an attempt record.
64
+ #
65
+ # @param assignment_id [String] Assignment ID
66
+ # @param attempt_id [String] Attempt ID
67
+ # @return [Models::Attempt, nil] Attempt or nil when absent/corrupt
68
+ def load(assignment_id, attempt_id)
69
+ path = record_path(assignment_id, attempt_id)
70
+ return nil unless File.exist?(path)
71
+
72
+ Models::Attempt.from_h(JSON.parse(File.read(path)))
73
+ rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
74
+ warn "ace-assign: corrupt attempt record #{path}: #{e.message}" if Ace::Assign.debug?
75
+ nil
76
+ end
77
+
78
+ # The active attempt owning an assignment + scope, if any.
79
+ #
80
+ # @param assignment_id [String] Assignment ID
81
+ # @param scope [String] Step/subtree scope
82
+ # @return [Models::Attempt, nil] Active attempt or nil
83
+ def active(assignment_id, scope)
84
+ pointer = active_path(assignment_id, scope)
85
+ return nil unless File.exist?(pointer)
86
+
87
+ entry = JSON.parse(File.read(pointer))
88
+ attempt = load(assignment_id, entry["attempt_id"])
89
+ return nil unless attempt&.active?
90
+
91
+ attempt
92
+ rescue JSON::ParserError, TypeError
93
+ nil
94
+ end
95
+
96
+ # Claim subtree ownership for an attempt. Callers must hold
97
+ # {with_lock} for read-modify-write atomicity.
98
+ #
99
+ # @param assignment_id [String] Assignment ID
100
+ # @param scope [String] Canonical scope
101
+ # @param attempt [Models::Attempt] Active attempt claiming the scope
102
+ def claim(assignment_id, scope, attempt)
103
+ FileUtils.mkdir_p(active_dir(assignment_id))
104
+ atomic_write(
105
+ active_path(assignment_id, scope),
106
+ JSON.generate({"attempt_id" => attempt.attempt_id})
107
+ )
108
+ end
109
+
110
+ # Release subtree ownership when it still references the attempt.
111
+ #
112
+ # @param assignment_id [String] Assignment ID
113
+ # @param scope [String] Canonical scope
114
+ # @param attempt_id [String] Attempt releasing ownership
115
+ def release(assignment_id, scope, attempt_id)
116
+ pointer = active_path(assignment_id, scope)
117
+ return unless File.exist?(pointer)
118
+
119
+ entry = JSON.parse(File.read(pointer))
120
+ File.delete(pointer) if entry["attempt_id"] == attempt_id
121
+ rescue JSON::ParserError, TypeError
122
+ nil
123
+ end
124
+
125
+ # Atomically reserve an attempt ID across all assignments
126
+ # (mkdir-based EEXIST check). Returns false when already taken.
127
+ #
128
+ # @param attempt_id [String] Candidate ID
129
+ # @return [Boolean] True when newly reserved
130
+ def reserve_id(attempt_id)
131
+ registry = File.join(@cache_base, ".attempt-ids")
132
+ FileUtils.mkdir_p(registry)
133
+ Dir.mkdir(File.join(registry, attempt_id))
134
+ true
135
+ rescue Errno::EEXIST
136
+ false
137
+ end
138
+
139
+ # Locate an attempt record by ID across all assignments (used by
140
+ # reconcile, which receives only the attempt ID).
141
+ #
142
+ # @param attempt_id [String] Attempt ID
143
+ # @return [Models::Attempt, nil] Attempt or nil
144
+ def find(attempt_id)
145
+ path = Dir.glob(File.join(@cache_base, "*", "attempts", "records", "#{attempt_id}.json")).first
146
+ return nil unless path
147
+
148
+ assignment_id = File.basename(File.dirname(File.dirname(File.dirname(path))))
149
+ load(assignment_id, attempt_id)
150
+ end
151
+
152
+ # All attempt records for an assignment, newest first. Ties (records
153
+ # serialized within the same second) break deterministically by
154
+ # attempt ID.
155
+ #
156
+ # @param assignment_id [String] Assignment ID
157
+ # @return [Array<Models::Attempt>] Persisted attempts
158
+ def list(assignment_id)
159
+ Dir.glob(File.join(records_dir(assignment_id), "*.json"))
160
+ .map { |path| load(assignment_id, File.basename(path, ".json")) }
161
+ .compact
162
+ .sort_by { |attempt| [attempt.updated_at.utc, attempt.attempt_id] }
163
+ .reverse
164
+ end
165
+
166
+ private
167
+
168
+ def records_dir(assignment_id)
169
+ File.join(attempts_dir(assignment_id), "records")
170
+ end
171
+
172
+ def active_dir(assignment_id)
173
+ File.join(attempts_dir(assignment_id), "active")
174
+ end
175
+
176
+ def lock_path(assignment_id)
177
+ File.join(attempts_dir(assignment_id), ".lock")
178
+ end
179
+
180
+ def record_path(assignment_id, attempt_id)
181
+ File.join(records_dir(assignment_id), "#{attempt_id}.json")
182
+ end
183
+
184
+ def active_path(assignment_id, scope)
185
+ File.join(active_dir(assignment_id), Atoms::AssignmentScope.ownership_key(assignment_id, scope))
186
+ end
187
+
188
+ # Atomic same-filesystem write: temp file plus rename.
189
+ def atomic_write(path, content)
190
+ dir = File.dirname(path)
191
+ FileUtils.mkdir_p(dir)
192
+ temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
193
+ File.write(temp, content)
194
+ File.rename(temp, path)
195
+ ensure
196
+ File.delete(temp) if temp && File.exist?(temp)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end