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
|
@@ -0,0 +1,135 @@
|
|
|
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, :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, 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
|
+
@recorded_at = recorded_at
|
|
56
|
+
@digest = digest || Atoms::EvidenceDigest.digest(digest_payload)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Canonical payload covered by the receipt digest (excludes the digest
|
|
60
|
+
# itself and the record timestamp, which is assigned at acceptance).
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] Digest payload
|
|
63
|
+
def digest_payload
|
|
64
|
+
{
|
|
65
|
+
"attempt_id" => attempt_id,
|
|
66
|
+
"assignment_id" => assignment_id,
|
|
67
|
+
"project_id" => project_id,
|
|
68
|
+
"scope" => scope,
|
|
69
|
+
"operation" => operation,
|
|
70
|
+
"producer" => producer,
|
|
71
|
+
"head" => head,
|
|
72
|
+
"verdict" => verdict,
|
|
73
|
+
"artifacts" => artifacts,
|
|
74
|
+
"checks" => checks,
|
|
75
|
+
"review" => review
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert to a hash for serialization.
|
|
80
|
+
# @return [Hash] Receipt data with string keys
|
|
81
|
+
def to_h
|
|
82
|
+
digest_payload.merge(
|
|
83
|
+
"recorded_at" => recorded_at.iso8601,
|
|
84
|
+
"digest" => digest
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rebuild a receipt from serialized or submitted data.
|
|
89
|
+
#
|
|
90
|
+
# @param data [Hash] Receipt data (string keys); missing digest is computed
|
|
91
|
+
# @return [ExecutionReceipt] Receipt instance
|
|
92
|
+
def self.from_h(data)
|
|
93
|
+
new(
|
|
94
|
+
attempt_id: data["attempt_id"],
|
|
95
|
+
assignment_id: data["assignment_id"],
|
|
96
|
+
project_id: data["project_id"],
|
|
97
|
+
scope: data["scope"],
|
|
98
|
+
operation: data["operation"],
|
|
99
|
+
producer: data["producer"] || {},
|
|
100
|
+
head: data["head"],
|
|
101
|
+
verdict: data["verdict"],
|
|
102
|
+
artifacts: data["artifacts"] || [],
|
|
103
|
+
checks: data["checks"] || [],
|
|
104
|
+
review: data["review"],
|
|
105
|
+
recorded_at: parse_time(data["recorded_at"]) || Time.now.utc,
|
|
106
|
+
digest: data["digest"]
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reject payloads that carry forbidden fields anywhere in the tree.
|
|
111
|
+
#
|
|
112
|
+
# @param data [Hash] Raw submitted receipt data
|
|
113
|
+
# @return [Boolean] True if a forbidden field is present
|
|
114
|
+
def self.forbidden_field?(data)
|
|
115
|
+
case data
|
|
116
|
+
when Hash
|
|
117
|
+
data.any? { |key, value| FORBIDDEN_FIELDS.include?(key.to_s) || forbidden_field?(value) }
|
|
118
|
+
when Array
|
|
119
|
+
data.any? { |item| forbidden_field?(item) }
|
|
120
|
+
else
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.parse_time(value)
|
|
126
|
+
return nil if value.nil?
|
|
127
|
+
return value if value.is_a?(Time)
|
|
128
|
+
require "time"
|
|
129
|
+
Time.parse(value)
|
|
130
|
+
end
|
|
131
|
+
private_class_method :parse_time
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
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
|