ace-herdr 0.1.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 +7 -0
- data/.ace-defaults/herdr/config.yml +26 -0
- data/.ace-defaults/herdr/tabs/agent.yml +11 -0
- data/.ace-defaults/herdr/workspaces/development.yml +23 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +12 -0
- data/docs/usage.md +306 -0
- data/exe/ace-herdr +17 -0
- data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
- data/lib/ace/herdr/cli/commands/capture.rb +55 -0
- data/lib/ace/herdr/cli/commands/close.rb +53 -0
- data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
- data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
- data/lib/ace/herdr/cli/commands/list.rb +71 -0
- data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
- data/lib/ace/herdr/cli/commands/send.rb +103 -0
- data/lib/ace/herdr/cli/commands/support.rb +68 -0
- data/lib/ace/herdr/cli/commands/tab.rb +54 -0
- data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
- data/lib/ace/herdr/cli/commands/wait.rb +88 -0
- data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
- data/lib/ace/herdr/cli.rb +98 -0
- data/lib/ace/herdr/errors.rb +51 -0
- data/lib/ace/herdr/models/delivery_record.rb +109 -0
- data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
- data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
- data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
- data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
- data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
- data/lib/ace/herdr/organisms/control_surface.rb +575 -0
- data/lib/ace/herdr/organisms/deliverer.rb +358 -0
- data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
- data/lib/ace/herdr/organisms/tidy.rb +188 -0
- data/lib/ace/herdr/version.rb +7 -0
- data/lib/ace/herdr.rb +103 -0
- metadata +224 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "ace/core"
|
|
5
|
+
require_relative "../herdr"
|
|
6
|
+
require_relative "cli/commands/deliver"
|
|
7
|
+
require_relative "cli/commands/dispatch"
|
|
8
|
+
require_relative "cli/commands/wait"
|
|
9
|
+
require_relative "cli/commands/close"
|
|
10
|
+
require_relative "cli/commands/tidy"
|
|
11
|
+
require_relative "cli/commands/list"
|
|
12
|
+
require_relative "cli/commands/send"
|
|
13
|
+
require_relative "cli/commands/capture"
|
|
14
|
+
require_relative "cli/commands/workspace"
|
|
15
|
+
require_relative "cli/commands/tab"
|
|
16
|
+
require_relative "cli/commands/list_presets"
|
|
17
|
+
|
|
18
|
+
module Ace
|
|
19
|
+
module Herdr
|
|
20
|
+
# ace-support-cli based CLI registry for ace-herdr
|
|
21
|
+
module CLI
|
|
22
|
+
extend Ace::Support::Cli::RegistryDsl
|
|
23
|
+
|
|
24
|
+
PROGRAM_NAME = "ace-herdr"
|
|
25
|
+
|
|
26
|
+
# Application commands with descriptions (for help output)
|
|
27
|
+
REGISTERED_COMMANDS = [
|
|
28
|
+
["deliver", "Push an answer to an agent pane (ace-hitl delivery contract)"],
|
|
29
|
+
["dispatch", "Start an agent in one command: tab + agent + prompt"],
|
|
30
|
+
["list", "List live panes, tabs, or workspaces as one JSON line"],
|
|
31
|
+
["send", "Send a command, raw text, or named keys to a pane"],
|
|
32
|
+
["capture", "Print recent pane output as raw text"],
|
|
33
|
+
["wait", "Wait for an agent state or matching pane output"],
|
|
34
|
+
["close", "Rename and/or close a finished agent pane"],
|
|
35
|
+
["tidy", "Report cleanable panes/delivery records; close and archive with --apply"],
|
|
36
|
+
["workspace", "Create a workspace from a preset"],
|
|
37
|
+
["tab", "Create a tab from a preset"],
|
|
38
|
+
["--list-presets", "List available workspace/tab presets"]
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
HELP_EXAMPLES = [
|
|
42
|
+
"ace-herdr deliver --session ws-1 --pane p5 --event-id evt-1 --answer-file answer.md",
|
|
43
|
+
"echo 'the answer' | ace-herdr deliver --pane p5",
|
|
44
|
+
"ace-herdr dispatch --label 8wm.t.vs0 --kind pi --prompt-file prompt.md",
|
|
45
|
+
"ace-herdr list --workspace w1",
|
|
46
|
+
"ace-herdr send --pane p5 --cmd 'bundle exec rake test'",
|
|
47
|
+
"ace-herdr send --pane p5 --msg 'continue' --key Enter",
|
|
48
|
+
"ace-herdr capture --pane p5 --lines 40",
|
|
49
|
+
"ace-herdr wait --pane p5 --for output --pattern done --timeout 30",
|
|
50
|
+
"ace-herdr workspace development",
|
|
51
|
+
"ace-herdr tab agent --workspace w1",
|
|
52
|
+
"ace-herdr --list-presets",
|
|
53
|
+
"ace-herdr close --pane p5 --rename done",
|
|
54
|
+
"ace-herdr tidy --apply"
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Start the CLI
|
|
58
|
+
#
|
|
59
|
+
# @param args [Array<String>] Command-line arguments
|
|
60
|
+
# @return [Integer] Exit code (0 for success, non-zero for failure)
|
|
61
|
+
def self.start(args)
|
|
62
|
+
Ace::Support::Cli::Runner.new(self).call(args: args)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Register commands
|
|
66
|
+
register "deliver", CLI::Commands::Deliver.new
|
|
67
|
+
register "dispatch", CLI::Commands::Dispatch.new
|
|
68
|
+
register "list", CLI::Commands::List.new
|
|
69
|
+
register "send", CLI::Commands::Send.new
|
|
70
|
+
register "capture", CLI::Commands::Capture.new
|
|
71
|
+
register "wait", CLI::Commands::Wait.new
|
|
72
|
+
register "close", CLI::Commands::Close.new
|
|
73
|
+
register "tidy", CLI::Commands::Tidy.new
|
|
74
|
+
register "workspace", CLI::Commands::Workspace.new
|
|
75
|
+
register "tab", CLI::Commands::Tab.new
|
|
76
|
+
register "--list-presets", CLI::Commands::ListPresets.new
|
|
77
|
+
|
|
78
|
+
# Register version command
|
|
79
|
+
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
80
|
+
gem_name: "ace-herdr",
|
|
81
|
+
version: Ace::Herdr::VERSION
|
|
82
|
+
)
|
|
83
|
+
register "version", version_cmd
|
|
84
|
+
register "--version", version_cmd
|
|
85
|
+
|
|
86
|
+
# Register help command
|
|
87
|
+
help_cmd = Ace::Support::Cli::HelpCommand.build(
|
|
88
|
+
program_name: PROGRAM_NAME,
|
|
89
|
+
version: Ace::Herdr::VERSION,
|
|
90
|
+
commands: REGISTERED_COMMANDS,
|
|
91
|
+
examples: HELP_EXAMPLES
|
|
92
|
+
)
|
|
93
|
+
register "help", help_cmd
|
|
94
|
+
register "--help", help_cmd
|
|
95
|
+
register "-h", help_cmd
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Herdr
|
|
5
|
+
# Base for herdr CLI executor failures; retry classification drives the
|
|
6
|
+
# ace-hitl DeliverResult state mapping (retryable vs terminal).
|
|
7
|
+
class ExecutorError < Error
|
|
8
|
+
# Terminal by default: retrying with identical content cannot succeed
|
|
9
|
+
def retryable?
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Agent rejected the prompt before any input was sent (agent_blocked)
|
|
15
|
+
class AgentBlockedError < ExecutorError; end
|
|
16
|
+
|
|
17
|
+
# Accepted prompt did not observe a working/blocked state in time
|
|
18
|
+
# (agent_prompt_stalled / wait timeout) — transient, safe to re-push
|
|
19
|
+
class AgentNotReadyError < ExecutorError
|
|
20
|
+
def retryable?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Target pane does not exist (pane_not_found)
|
|
26
|
+
class PaneNotFoundError < ExecutorError; end
|
|
27
|
+
|
|
28
|
+
# Target tab does not exist (tab_not_found)
|
|
29
|
+
class TabNotFoundError < ExecutorError; end
|
|
30
|
+
|
|
31
|
+
# Target workspace does not exist (workspace_not_found)
|
|
32
|
+
class WorkspaceNotFoundError < ExecutorError; end
|
|
33
|
+
|
|
34
|
+
# No agent detected in the target pane
|
|
35
|
+
class AgentNotFoundError < ExecutorError; end
|
|
36
|
+
|
|
37
|
+
# herdr CLI binary or its socket is unreachable — transient
|
|
38
|
+
class ExecutorUnavailableError < ExecutorError
|
|
39
|
+
def retryable?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Unclassified herdr CLI failure — treated as transient within retry limits
|
|
45
|
+
class CommandError < ExecutorError
|
|
46
|
+
def retryable?
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Herdr
|
|
7
|
+
module Models
|
|
8
|
+
# Write-ahead record of one push delivery. Immutable value object:
|
|
9
|
+
# state transitions return new instances; persistence lives in
|
|
10
|
+
# Molecules::DeliveryRecordStore.
|
|
11
|
+
#
|
|
12
|
+
# States: pending (written before first herdr contact), delivered,
|
|
13
|
+
# retryable (exhausted transient attempts; safe to re-push identical
|
|
14
|
+
# content), failed (terminal).
|
|
15
|
+
#
|
|
16
|
+
# The record carries the full answer so a crash after write-ahead but
|
|
17
|
+
# before the prompt never loses the content; it can be re-delivered
|
|
18
|
+
# via resume. Record files are 0600 (answers may be sensitive).
|
|
19
|
+
class DeliveryRecord
|
|
20
|
+
STATES = %w[pending delivered retryable failed].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :event_id, :session, :pane, :answer_digest, :answer,
|
|
23
|
+
:state, :attempts, :history, :created_at, :updated_at
|
|
24
|
+
|
|
25
|
+
def initialize(event_id:, session:, pane:, answer_digest:, answer: nil,
|
|
26
|
+
state: "pending", attempts: 0, history: [], created_at: nil, updated_at: nil)
|
|
27
|
+
raise ArgumentError, "unknown state: #{state}" unless STATES.include?(state)
|
|
28
|
+
|
|
29
|
+
@event_id = event_id
|
|
30
|
+
@session = session
|
|
31
|
+
@pane = pane
|
|
32
|
+
@answer_digest = answer_digest
|
|
33
|
+
@answer = answer
|
|
34
|
+
@state = state
|
|
35
|
+
@attempts = attempts
|
|
36
|
+
@history = history.dup.freeze
|
|
37
|
+
@created_at = created_at
|
|
38
|
+
@updated_at = updated_at
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.from_h(hash)
|
|
43
|
+
new(
|
|
44
|
+
event_id: hash["event_id"], session: hash["session"], pane: hash["pane"],
|
|
45
|
+
answer_digest: hash["answer_digest"], answer: hash["answer"],
|
|
46
|
+
state: hash["state"] || "pending",
|
|
47
|
+
attempts: hash["attempts"] || 0, history: hash["history"] || [],
|
|
48
|
+
created_at: hash["created_at"], updated_at: hash["updated_at"]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_json(json)
|
|
53
|
+
from_h(JSON.parse(json))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
{
|
|
58
|
+
"event_id" => event_id, "session" => session, "pane" => pane,
|
|
59
|
+
"answer_digest" => answer_digest, "answer" => answer,
|
|
60
|
+
"state" => state,
|
|
61
|
+
"attempts" => attempts, "history" => history,
|
|
62
|
+
"created_at" => created_at, "updated_at" => updated_at
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delivered?
|
|
67
|
+
state == "delivered"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A "submitting" history entry without a matching outcome means the
|
|
71
|
+
# previous run crashed between prompt submission and persistence;
|
|
72
|
+
# resending could duplicate the answer, so the outcome is ambiguous.
|
|
73
|
+
def ambiguous_submission?
|
|
74
|
+
last = history.last
|
|
75
|
+
last.is_a?(Hash) && last["action"] == "prompt" && last["outcome"] == "submitting"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return a copy with a history entry appended (no attempt counted);
|
|
79
|
+
# used for non-prompt events such as bootstrap actions
|
|
80
|
+
def append_event(detail:, timestamp:, state: nil)
|
|
81
|
+
self.class.new(
|
|
82
|
+
event_id: event_id, session: session, pane: pane,
|
|
83
|
+
answer_digest: answer_digest, answer: answer,
|
|
84
|
+
state: state || self.state,
|
|
85
|
+
attempts: attempts,
|
|
86
|
+
history: history + [detail.merge("at" => timestamp)],
|
|
87
|
+
created_at: created_at || timestamp, updated_at: timestamp
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Return a copy advanced to the next attempt with a history entry
|
|
92
|
+
# @param state [String] resulting state after the attempt
|
|
93
|
+
# @param detail [Hash] attempt detail (action, outcome, error)
|
|
94
|
+
# @param timestamp [String] RFC 3339 timestamp of the transition
|
|
95
|
+
def record_attempt(state:, detail:, timestamp:)
|
|
96
|
+
appended = append_event(state: state, detail: detail, timestamp: timestamp)
|
|
97
|
+
self.class.new(
|
|
98
|
+
event_id: event_id, session: session, pane: pane,
|
|
99
|
+
answer_digest: answer_digest, answer: answer,
|
|
100
|
+
state: state,
|
|
101
|
+
attempts: attempts + 1,
|
|
102
|
+
history: appended.history,
|
|
103
|
+
created_at: appended.created_at, updated_at: appended.updated_at
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Herdr
|
|
5
|
+
module Models
|
|
6
|
+
# Outcome of a one-command agent dispatch: pure data carrier
|
|
7
|
+
class DispatchOutcome
|
|
8
|
+
attr_reader :workspace_id, :pane, :agent_name, :kind, :tab_created, :prompted
|
|
9
|
+
|
|
10
|
+
def initialize(workspace_id:, pane:, agent_name:, kind:, tab_created:, prompted:)
|
|
11
|
+
@workspace_id = workspace_id
|
|
12
|
+
@pane = pane
|
|
13
|
+
@agent_name = agent_name
|
|
14
|
+
@kind = kind
|
|
15
|
+
@tab_created = tab_created
|
|
16
|
+
@prompted = prompted
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"workspace" => workspace_id, "pane" => pane, "agent" => agent_name,
|
|
23
|
+
"kind" => kind, "tab_created" => tab_created, "prompted" => prompted
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Molecules
|
|
9
|
+
# Persists DeliveryRecord JSON files under a deliveries directory.
|
|
10
|
+
# Writes are atomic (temp file + rename, mode 0600) so a crash never
|
|
11
|
+
# yields a torn record and the answer stays re-deliverable. A per-event
|
|
12
|
+
# flock serializes concurrent deliveries of the same event.
|
|
13
|
+
module DeliveryRecordStore
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @return [Models::DeliveryRecord, nil] the record, falling back to
|
|
17
|
+
# the archive copy when the live record is gone — delivery
|
|
18
|
+
# idempotency (identical short-circuit, conflict fail-closed) must
|
|
19
|
+
# survive tidy archival
|
|
20
|
+
# @raise on malformed/unreadable content (fail closed: callers must
|
|
21
|
+
# not treat a corrupt record as absent)
|
|
22
|
+
def load(deliveries_dir, event_id)
|
|
23
|
+
path = resolve_record_path(deliveries_dir, event_id)
|
|
24
|
+
return nil unless path
|
|
25
|
+
|
|
26
|
+
Models::DeliveryRecord.from_json(File.read(path))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Tidy's lock-guarded revalidation load: like load, but a malformed
|
|
30
|
+
# or unreadable record decodes as :unreadable instead of raising
|
|
31
|
+
# (reported as preserved, never archived on unprovable evidence)
|
|
32
|
+
# @return [Models::DeliveryRecord, nil, :unreadable]
|
|
33
|
+
def load_revalidated(deliveries_dir, event_id)
|
|
34
|
+
path = resolve_record_path(deliveries_dir, event_id)
|
|
35
|
+
return nil unless path
|
|
36
|
+
|
|
37
|
+
Models::DeliveryRecord.from_h(JSON.parse(File.read(path)))
|
|
38
|
+
rescue JSON::ParserError, ArgumentError, TypeError, NoMethodError, Errno::EACCES
|
|
39
|
+
:unreadable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] path the record was written to
|
|
43
|
+
def save(record, deliveries_dir)
|
|
44
|
+
FileUtils.mkdir_p(deliveries_dir)
|
|
45
|
+
path = path_for(deliveries_dir, record.event_id)
|
|
46
|
+
tmp = "#{path}.tmp.#{Process.pid}"
|
|
47
|
+
File.open(tmp, IO::CREAT | IO::TRUNC | IO::WRONLY, 0o600) do |file|
|
|
48
|
+
file.write(JSON.generate(record.to_h))
|
|
49
|
+
end
|
|
50
|
+
File.rename(tmp, path)
|
|
51
|
+
path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Hold the exclusive per-event lock while delivering; concurrent
|
|
55
|
+
# callers block until the winner finishes, then observe its record.
|
|
56
|
+
def with_lock(deliveries_dir, event_id)
|
|
57
|
+
FileUtils.mkdir_p(deliveries_dir)
|
|
58
|
+
File.open(lock_path(deliveries_dir, event_id), "a") do |lock|
|
|
59
|
+
lock.flock(File::LOCK_EX)
|
|
60
|
+
begin
|
|
61
|
+
return yield
|
|
62
|
+
ensure
|
|
63
|
+
lock.flock(File::LOCK_UN)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def path_for(deliveries_dir, event_id)
|
|
69
|
+
File.join(deliveries_dir, "#{event_id}.json")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def lock_path(deliveries_dir, event_id)
|
|
73
|
+
File.join(deliveries_dir, ".#{event_id}.lock")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --- tidy (spec 8wq.t.1w0) -------------------------------------------
|
|
77
|
+
|
|
78
|
+
# Enumerate the top-level delivery records, sorted by event id.
|
|
79
|
+
# Lock files, temp files, and the archive directory are ignored;
|
|
80
|
+
# a malformed or unreadable file yields an entry with record: nil
|
|
81
|
+
# (reported as preserved, never removed).
|
|
82
|
+
# @return [Array<{event_id: String, record: Models::DeliveryRecord, nil}>]
|
|
83
|
+
def list_records(deliveries_dir)
|
|
84
|
+
return [] unless Dir.exist?(deliveries_dir)
|
|
85
|
+
|
|
86
|
+
Dir.children(deliveries_dir).sort.filter_map do |name|
|
|
87
|
+
next nil unless name.end_with?(".json")
|
|
88
|
+
next nil unless record_file?(File.join(deliveries_dir, name))
|
|
89
|
+
|
|
90
|
+
event_id = name.delete_suffix(".json")
|
|
91
|
+
record =
|
|
92
|
+
begin
|
|
93
|
+
read_record(File.join(deliveries_dir, name))
|
|
94
|
+
rescue Errno::EACCES
|
|
95
|
+
nil # unreadable: reported as preserved, never removed
|
|
96
|
+
rescue Errno::ENOENT
|
|
97
|
+
next nil # vanished between listing and read: nothing to preserve
|
|
98
|
+
end
|
|
99
|
+
{event_id: event_id, record: record}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Archive directory for retired delivered records (inside the
|
|
104
|
+
# deliveries dir so a same-filesystem rename stays atomic)
|
|
105
|
+
def archive_dir(deliveries_dir)
|
|
106
|
+
File.join(deliveries_dir, "archive")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Atomically move one record file into the archive directory
|
|
110
|
+
# (rename preserves the 0600 mode). Callers must hold the per-event
|
|
111
|
+
# lock and re-check eligibility under it. Idempotent: an already
|
|
112
|
+
# archived event just reports its archive path.
|
|
113
|
+
# @return [String] the archive path the record lives at
|
|
114
|
+
def archive(deliveries_dir, event_id)
|
|
115
|
+
dest = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
|
|
116
|
+
return dest if !File.exist?(path_for(deliveries_dir, event_id)) && File.exist?(dest)
|
|
117
|
+
|
|
118
|
+
FileUtils.mkdir_p(archive_dir(deliveries_dir))
|
|
119
|
+
File.rename(path_for(deliveries_dir, event_id), dest)
|
|
120
|
+
dest
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def record_file?(path)
|
|
124
|
+
File.file?(path) && !File.basename(path).start_with?(".")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Live record path, falling back to the archive copy
|
|
128
|
+
def resolve_record_path(deliveries_dir, event_id)
|
|
129
|
+
live = path_for(deliveries_dir, event_id)
|
|
130
|
+
return live if File.exist?(live)
|
|
131
|
+
|
|
132
|
+
archived = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
|
|
133
|
+
File.exist?(archived) ? archived : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Decode a record file fail-closed: anything that is not a decodable
|
|
137
|
+
# delivery record (malformed JSON, non-object JSON, unknown state)
|
|
138
|
+
# reads as nil (preserved/unreadable), never raises
|
|
139
|
+
def read_record(path)
|
|
140
|
+
parsed = JSON.parse(File.read(path))
|
|
141
|
+
return nil unless parsed.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
Models::DeliveryRecord.from_h(parsed)
|
|
144
|
+
rescue JSON::ParserError, ArgumentError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|