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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/herdr/config.yml +26 -0
  3. data/.ace-defaults/herdr/tabs/agent.yml +11 -0
  4. data/.ace-defaults/herdr/workspaces/development.yml +23 -0
  5. data/CHANGELOG.md +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +54 -0
  8. data/Rakefile +12 -0
  9. data/docs/usage.md +306 -0
  10. data/exe/ace-herdr +17 -0
  11. data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
  12. data/lib/ace/herdr/cli/commands/capture.rb +55 -0
  13. data/lib/ace/herdr/cli/commands/close.rb +53 -0
  14. data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
  15. data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
  16. data/lib/ace/herdr/cli/commands/list.rb +71 -0
  17. data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
  18. data/lib/ace/herdr/cli/commands/send.rb +103 -0
  19. data/lib/ace/herdr/cli/commands/support.rb +68 -0
  20. data/lib/ace/herdr/cli/commands/tab.rb +54 -0
  21. data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
  22. data/lib/ace/herdr/cli/commands/wait.rb +88 -0
  23. data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
  24. data/lib/ace/herdr/cli.rb +98 -0
  25. data/lib/ace/herdr/errors.rb +51 -0
  26. data/lib/ace/herdr/models/delivery_record.rb +109 -0
  27. data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
  28. data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
  29. data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
  30. data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
  31. data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
  32. data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
  33. data/lib/ace/herdr/organisms/control_surface.rb +575 -0
  34. data/lib/ace/herdr/organisms/deliverer.rb +358 -0
  35. data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
  36. data/lib/ace/herdr/organisms/tidy.rb +188 -0
  37. data/lib/ace/herdr/version.rb +7 -0
  38. data/lib/ace/herdr.rb +103 -0
  39. metadata +224 -0
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "time"
5
+ require "ace/hitl"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Organisms
10
+ # Push delivery per the ace-hitl provider delivery contract
11
+ # (spec 8wm.t.vrz §1.2, task 8wm.t.vs0):
12
+ # deliver(ref, answer) -> Ace::Hitl::Providers::DeliverResult
13
+ # with state :delivered, :retryable or :failed.
14
+ #
15
+ # Guarantees:
16
+ # - Idempotent per event id: a delivered record short-circuits
17
+ # identical content; different content or a different destination
18
+ # for the same id fails closed.
19
+ # - The answer is never lost: the record carries the full answer and
20
+ # is persisted before the first herdr contact (0600, atomic) and
21
+ # around every attempt; a crashed run can be resumed with #resume.
22
+ # - Concurrent deliveries of one event serialize on a per-event lock.
23
+ # - An ambiguous crash window (prompt submitted, outcome not yet
24
+ # persisted) is reported as :failed instead of silently resending.
25
+ # - A pane without an agent is bootstrapped (herdr agent start) with
26
+ # the reverse address exported into the pane shell, then gated on
27
+ # readiness before the prompt is submitted.
28
+ # - Transient failures (probe or prompt) persist their history and
29
+ # return :retryable within retry limits; terminal failures return
30
+ # :failed with the error persisted in the record.
31
+ class Deliverer
32
+ READY_STATES = %w[idle].freeze
33
+
34
+ # executor: seam responding to the Molecules::HerdrExecutor API
35
+ # clock: sleeper for backoff, injectable for tests (default Kernel.sleep)
36
+ def initialize(executor:, deliveries_dir:, max_attempts:, backoff_seconds:,
37
+ default_agent_kind:, agent_start_timeout_ms:, readiness_timeout_ms:, clock: nil)
38
+ @executor = executor
39
+ @deliveries_dir = deliveries_dir
40
+ @max_attempts = max_attempts
41
+ @backoff_seconds = backoff_seconds
42
+ @default_agent_kind = default_agent_kind
43
+ @agent_start_timeout_ms = agent_start_timeout_ms
44
+ @readiness_timeout_ms = readiness_timeout_ms
45
+ @clock = clock || ->(seconds) { Kernel.sleep(seconds) }
46
+ end
47
+
48
+ # Build a Deliverer from the merged config cascade hash (string keys)
49
+ def self.from_config(executor:, deliveries_dir:, config: {})
50
+ delivery = config["delivery"] || {}
51
+ timeouts = config["timeouts"] || {}
52
+ new(
53
+ executor: executor,
54
+ deliveries_dir: deliveries_dir,
55
+ max_attempts: delivery["max_attempts"] || 3,
56
+ backoff_seconds: delivery["backoff_seconds"] || [1, 2, 4],
57
+ default_agent_kind: config["default_agent_kind"] || "pi",
58
+ agent_start_timeout_ms: (timeouts["agent_start"] || 60) * 1000,
59
+ readiness_timeout_ms: (timeouts["wait"] || 30) * 1000
60
+ )
61
+ end
62
+
63
+ # @param ref [Ace::Hitl::Providers::Ref, Hash] reverse address; a Hash
64
+ # with "session"/"pane" keys (persisted event fields) is coerced.
65
+ # @param answer [String] content pushed to the pane
66
+ # @param event_id [String, nil] idempotency key; derived from the
67
+ # ref and content digest when omitted
68
+ # @param kind [String, nil] agent kind for bootstrap
69
+ # (default: config default_agent_kind)
70
+ # @param label [String, nil] agent name used when bootstrapping
71
+ # (default: the event id)
72
+ # @return [Ace::Hitl::Providers::DeliverResult]
73
+ # @raise [Ace::Hitl::Providers::InvalidRefError] invalid reverse address
74
+ # @raise [ValidationError] event id, destination or content conflict
75
+ def deliver(ref, answer, event_id: nil, kind: nil, label: nil)
76
+ ref = coerce_ref(ref)
77
+ raise ValidationError, "answer is required" if answer.to_s.empty?
78
+
79
+ digest = Ace::Herdr::Atoms::AnswerDigest.call(answer)
80
+ event_id = normalize_event_id(event_id, ref, digest)
81
+
82
+ Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
83
+ record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
84
+ validate_existing_record(record, ref, digest) if record
85
+ record ||= Models::DeliveryRecord.new(
86
+ event_id: event_id, session: ref.session, pane: ref.pane,
87
+ answer_digest: digest, answer: answer
88
+ )
89
+ deliver_locked(ref, answer, record, kind, label)
90
+ end
91
+ end
92
+
93
+ # Re-deliver from the persisted record after a crash. The answer and
94
+ # destination come from the record; no fresh content is needed.
95
+ # @return [Ace::Hitl::Providers::DeliverResult]
96
+ # @raise [ValidationError] no recoverable record for the event id
97
+ def resume(event_id, kind: nil, label: nil)
98
+ validate_event_id!(event_id)
99
+ Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
100
+ record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
101
+ if record.nil? || record.answer.to_s.empty?
102
+ raise ValidationError,
103
+ "no recoverable answer stored for event #{event_id.inspect}"
104
+ end
105
+
106
+ ref = Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane)
107
+ deliver_locked(ref, record.answer, record, kind, label)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def deliver_locked(ref, answer, record, kind, label)
114
+ if record.delivered?
115
+ return DeliverResult(ref: ref, state: :delivered)
116
+ end
117
+ if record.state == "failed"
118
+ # Terminal: retrying would need a new event id, never a resend
119
+ return DeliverResult(ref: ref, state: :failed)
120
+ end
121
+ if record.ambiguous_submission?
122
+ # The previous run may have already pushed this answer; resending
123
+ # could duplicate it, so report instead of acting.
124
+ record = record.append_event(
125
+ state: "failed",
126
+ detail: {action: "reconcile", outcome: "ambiguous",
127
+ error: "previous run crashed after submitting; resolve manually"},
128
+ timestamp: now
129
+ )
130
+ persist(record)
131
+ return DeliverResult(ref: ref, state: :failed)
132
+ end
133
+ persist(record) if record.attempts.zero? && record.history.empty?
134
+
135
+ deliver_with_bootstrap(ref, answer, record, kind, label)
136
+ end
137
+
138
+ def deliver_with_bootstrap(ref, answer, record, kind, label)
139
+ outcome = probe_agent(ref, record, kind, label)
140
+ return outcome.result if outcome.terminal?
141
+
142
+ push_prompt(ref, answer, outcome.record)
143
+ end
144
+
145
+ # Probe the pane and bootstrap a missing agent. Returns a terminal
146
+ # outcome carrying the final DeliverResult, or the record to prompt.
147
+ # Transient probe failures retry within the configured limits.
148
+ def probe_agent(ref, record, kind, label)
149
+ attempt = 0
150
+ loop do
151
+ @executor.agent_get(ref.pane)
152
+ return Outcome.present(record)
153
+ rescue PaneNotFoundError => e
154
+ return Outcome.terminal(terminal(ref, record, e, action: "probe"))
155
+ rescue AgentNotFoundError
156
+ return bootstrap_and_wait(ref, record, kind, label)
157
+ rescue ExecutorError => e
158
+ attempt += 1
159
+ exhausted = attempt >= @max_attempts
160
+ record = record.append_event(
161
+ state: exhausted ? "retryable" : "pending",
162
+ detail: {action: "probe", attempt: attempt, outcome: e.class.name,
163
+ error: e.message},
164
+ timestamp: now
165
+ )
166
+ persist(record)
167
+ if exhausted
168
+ return Outcome.terminal(DeliverResult(ref: ref, state: :retryable))
169
+ end
170
+
171
+ @clock.call(backoff_for(attempt))
172
+ end
173
+ end
174
+
175
+ # Bootstrap a missing agent, then gate on readiness. Returns a
176
+ # terminal outcome when bootstrapping or readiness terminates the
177
+ # delivery; otherwise the record to prompt. Bootstrap failures are
178
+ # terminal: an immediate retry cannot heal a broken pane.
179
+ def bootstrap_and_wait(ref, record, kind, label)
180
+ export = "export HERDR_SESSION=#{Shellwords.escape(ref.session)} " \
181
+ "HERDR_PANE=#{Shellwords.escape(ref.pane)}"
182
+ begin
183
+ @executor.pane_run(ref.pane, export)
184
+ @executor.agent_start(
185
+ name: label || record.event_id, kind: kind || @default_agent_kind,
186
+ pane: ref.pane, timeout_ms: @agent_start_timeout_ms
187
+ )
188
+ rescue ExecutorError => e
189
+ return Outcome.terminal(terminal(ref, record, e, action: "bootstrap"))
190
+ end
191
+
192
+ record = record.append_event(
193
+ detail: {action: "bootstrap", outcome: "agent started"},
194
+ timestamp: now
195
+ )
196
+ persist(record)
197
+
198
+ begin
199
+ @executor.agent_wait(
200
+ pane: ref.pane, until_states: READY_STATES,
201
+ timeout_ms: @readiness_timeout_ms
202
+ )
203
+ rescue ExecutorError => e
204
+ # Started but not yet ready: do not prompt; safe to retry later
205
+ record = record.append_event(
206
+ state: "retryable",
207
+ detail: {action: "readiness", outcome: "timeout", error: e.message},
208
+ timestamp: now
209
+ )
210
+ persist(record)
211
+ return Outcome.terminal(DeliverResult(ref: ref, state: :retryable))
212
+ end
213
+ Outcome.present(record)
214
+ end
215
+
216
+ # Prompt with retry limits and fixed deterministic backoff. Each
217
+ # attempt is written ahead (outcome "submitting") so an interrupted
218
+ # run never silently duplicates the submission.
219
+ def push_prompt(ref, answer, record)
220
+ attempt = 0
221
+ loop do
222
+ attempt += 1
223
+ record = record.append_event(
224
+ detail: {action: "prompt", outcome: "submitting"},
225
+ timestamp: now
226
+ )
227
+ persist(record)
228
+ begin
229
+ @executor.agent_prompt(pane: ref.pane, text: answer)
230
+ record = record.record_attempt(
231
+ state: "delivered",
232
+ detail: {action: "prompt", outcome: "delivered"},
233
+ timestamp: now
234
+ )
235
+ persist(record)
236
+ return DeliverResult(ref: ref, state: :delivered)
237
+ rescue ExecutorError => e
238
+ terminal = !e.retryable?
239
+ exhausted = !terminal && attempt >= @max_attempts
240
+ state = if terminal
241
+ "failed"
242
+ elsif exhausted
243
+ "retryable"
244
+ else
245
+ "pending"
246
+ end
247
+ record = record.record_attempt(
248
+ state: state,
249
+ detail: {action: "prompt", outcome: e.class.name, error: e.message},
250
+ timestamp: now
251
+ )
252
+ persist(record)
253
+ if terminal
254
+ return DeliverResult(ref: ref, state: :failed)
255
+ elsif exhausted
256
+ return DeliverResult(ref: ref, state: :retryable)
257
+ end
258
+
259
+ @clock.call(backoff_for(attempt))
260
+ end
261
+ end
262
+ end
263
+
264
+ # Fail closed when an existing record conflicts with this delivery
265
+ def validate_existing_record(record, ref, digest)
266
+ if record.answer_digest != digest || record.session != ref.session ||
267
+ record.pane != ref.pane
268
+ raise ValidationError,
269
+ "event #{record.event_id} was already used for a different " \
270
+ "answer or destination (idempotency conflict; fail closed)"
271
+ end
272
+ end
273
+
274
+ def terminal(ref, record, error, action:)
275
+ record = record.record_attempt(
276
+ state: "failed",
277
+ detail: {action: action, outcome: error.class.name, error: error.message},
278
+ timestamp: now
279
+ )
280
+ persist(record)
281
+ DeliverResult(ref: ref, state: :failed)
282
+ end
283
+
284
+ def persist(record)
285
+ Molecules::DeliveryRecordStore.save(record, @deliveries_dir)
286
+ end
287
+
288
+ def backoff_for(attempt)
289
+ @backoff_seconds[[attempt - 1, @backoff_seconds.length - 1].min].to_f
290
+ end
291
+
292
+ def coerce_ref(ref)
293
+ session, pane =
294
+ if ref.is_a?(Ace::Hitl::Providers::Ref)
295
+ [ref.session, ref.pane]
296
+ elsif ref.is_a?(Hash)
297
+ [ref["session"], ref["pane"]]
298
+ else
299
+ raise ValidationError, "ref must be an Ace::Hitl::Providers::Ref or a Hash"
300
+ end
301
+
302
+ Ace::Hitl::Providers::Ref.new(
303
+ session: Ace::Hitl::Providers::Ref.validate!(session, "ref session"),
304
+ pane: Ace::Hitl::Providers::Ref.validate!(pane, "ref pane")
305
+ )
306
+ end
307
+
308
+ def normalize_event_id(event_id, ref, digest)
309
+ return validate_event_id!(event_id) if event_id
310
+
311
+ seed = Ace::Herdr::Atoms::AnswerDigest.call("ref:#{ref.session}/#{ref.pane}:#{digest}")
312
+ "ans-#{seed[0, 24]}"
313
+ end
314
+
315
+ # Event ids become record file names and lock names; only tokens may
316
+ # pass (also blocks path traversal via resume)
317
+ def validate_event_id!(event_id)
318
+ token = event_id.to_s
319
+ unless token.match?(Ace::Hitl::Providers::Ref::TOKEN_PATTERN)
320
+ raise ValidationError,
321
+ "event id may only contain letters, digits, '.', '_', ':', '-' " \
322
+ "(got #{token.inspect})"
323
+ end
324
+
325
+ token
326
+ end
327
+
328
+ def now
329
+ Time.now.utc.iso8601
330
+ end
331
+
332
+ # Contract result type from ace-hitl (spec 8wm.t.vrz §1.2)
333
+ def DeliverResult(ref:, state:)
334
+ Ace::Hitl::Providers::DeliverResult.new(ref: ref, state: state)
335
+ end
336
+
337
+ # Probe/bootstrap phase outcome for deliver_with_bootstrap
338
+ class Outcome
339
+ attr_reader :record, :result
340
+
341
+ def initialize(record:, result: nil, terminal: false, bootstrapped: false)
342
+ @record = record
343
+ @result = result
344
+ @terminal = terminal
345
+ @bootstrapped = bootstrapped
346
+ end
347
+
348
+ def self.present(record) = new(record: record)
349
+ def self.bootstrapped(record) = new(record: record, bootstrapped: true)
350
+ def self.terminal(result) = new(record: nil, result: result, terminal: true)
351
+
352
+ def terminal? = @terminal
353
+ def bootstrapped? = @bootstrapped
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "ace/hitl"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Organisms
9
+ # One-command subagent dispatch (spec 8wm.t.vs0 scope extension):
10
+ # tab + herdr agent start + prompt with deterministic defaults —
11
+ # same workspace as the caller, label = agent name, prompt from
12
+ # file or stdin. Zero-token: no LLM decisions here.
13
+ class Dispatcher
14
+ PANE_KEYS = %w[pane_id paneId].freeze
15
+ WORKSPACE_KEYS = %w[workspace_id workspaceId].freeze
16
+
17
+ def initialize(executor:, default_agent_kind:, agent_start_timeout_ms:)
18
+ @executor = executor
19
+ @default_agent_kind = default_agent_kind
20
+ @agent_start_timeout_ms = agent_start_timeout_ms
21
+ end
22
+
23
+ def self.from_config(executor:, config: {})
24
+ timeouts = config["timeouts"] || {}
25
+ new(
26
+ executor: executor,
27
+ default_agent_kind: config["default_agent_kind"] || "pi",
28
+ agent_start_timeout_ms: (timeouts["agent_start"] || 60) * 1000
29
+ )
30
+ end
31
+
32
+ # @param label [String] agent name and pane label (typically the task id)
33
+ # @param prompt [String] initial prompt submitted after readiness
34
+ # @param kind [String, nil] agent kind (default: config default_agent_kind)
35
+ # @param workspace_id [String, nil] target herdr workspace
36
+ # (default: caller's workspace via env or pane current)
37
+ # @param pane [String, nil] existing pane to start the agent in;
38
+ # when omitted a new tab is created in the workspace
39
+ # @param cwd [String, nil] working directory for a created tab
40
+ # @return [Models::DispatchOutcome]
41
+ # @raise [TargetResolutionError] workspace/pane cannot be resolved
42
+ # @raise [ExecutorError] herdr command failures
43
+ def dispatch(label:, prompt:, kind: nil, workspace_id: nil, pane: nil, cwd: nil)
44
+ kind ||= @default_agent_kind
45
+ tab_created = false
46
+
47
+ workspace_id = resolve_workspace(workspace_id)
48
+ validate_token!(workspace_id, "workspace")
49
+ unless pane
50
+ pane = create_tab(workspace_id, label, cwd)
51
+ tab_created = true
52
+ end
53
+ validate_token!(pane, "pane")
54
+
55
+ @executor.pane_run(
56
+ pane,
57
+ "export HERDR_SESSION=#{Shellwords.escape(workspace_id)} " \
58
+ "HERDR_PANE=#{Shellwords.escape(pane)}"
59
+ )
60
+ @executor.agent_start(
61
+ name: label, kind: kind, pane: pane, timeout_ms: @agent_start_timeout_ms
62
+ )
63
+
64
+ prompted = false
65
+ unless prompt.empty?
66
+ @executor.agent_prompt(pane: pane, text: prompt)
67
+ prompted = true
68
+ end
69
+
70
+ Models::DispatchOutcome.new(
71
+ workspace_id: workspace_id, pane: pane, agent_name: label,
72
+ kind: kind, tab_created: tab_created, prompted: prompted
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ # Caller's workspace wins by default: explicit flag, then the herdr
79
+ # environment of the calling pane, then the pane current projection
80
+ def resolve_workspace(workspace_id)
81
+ return workspace_id if workspace_id
82
+
83
+ env = ENV["HERDR_WORKSPACE_ID"].to_s
84
+ return env unless env.empty?
85
+
86
+ json = @executor.pane_current.parsed_json
87
+ from_json(json, WORKSPACE_KEYS) ||
88
+ raise(TargetResolutionError,
89
+ "cannot resolve the caller's herdr workspace " \
90
+ "(pass --workspace, run inside a herdr pane, or start herdr)")
91
+ end
92
+
93
+ def create_tab(workspace_id, label, cwd)
94
+ result = @executor.tab_create(workspace_id: workspace_id, label: label, cwd: cwd)
95
+ from_json(result.parsed_json, PANE_KEYS) ||
96
+ raise(TargetResolutionError,
97
+ "could not read the new pane id from herdr tab create output " \
98
+ "(pass --pane with an existing pane instead)")
99
+ end
100
+
101
+ # Values reach a pane shell via the export line; only tokens may pass
102
+ def validate_token!(value, what)
103
+ return value if value.is_a?(String) && value.match?(Ace::Hitl::Providers::Ref::TOKEN_PATTERN)
104
+
105
+ raise ValidationError,
106
+ "#{what} contains invalid characters " \
107
+ "(allowed: letters, digits, '.', '_', ':', '-')"
108
+ end
109
+
110
+ def from_json(json, keys)
111
+ case json
112
+ when Hash
113
+ keys.each { |k| return json[k] if json[k].is_a?(String) && !json[k].empty? }
114
+ json.values.each do |value|
115
+ found = from_json(value, keys)
116
+ return found if found
117
+ end
118
+ nil
119
+ when Array
120
+ json.each do |value|
121
+ found = from_json(value, keys)
122
+ return found if found
123
+ end
124
+ nil
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ace
6
+ module Herdr
7
+ module Organisms
8
+ # Owner of tidy behavior (spec 8wq.t.1w0): dry-run-first cleanup of
9
+ # finished agent panes and old delivered delivery records. Safety rule:
10
+ # only positive evidence closes — a pane is closed solely on observed
11
+ # `done` agent state or a dead pane process, confirmed by a fresh probe
12
+ # immediately before the rename->close mutation (a candidate that
13
+ # revived in between is excluded, never mutated). Delivered records
14
+ # older than the retention threshold are archived only when a reload
15
+ # under the per-event lock still proves them eligible; failed,
16
+ # retryable, pending, and unreadable records are never touched.
17
+ class Tidy
18
+ DEFAULT_RETENTION_DAYS = 7
19
+ CLOSE_LABEL = "done"
20
+ DAY_SECONDS = 86_400
21
+ ELIGIBLE_EVIDENCE = %i[agent_done process_exited].freeze
22
+
23
+ attr_reader :retention_days
24
+
25
+ # @param executor [Molecules::HerdrExecutor] the exclusive native seam
26
+ # @param record_store [Molecules::DeliveryRecordStore] record access
27
+ # @param deliveries_dir [String] delivery record directory
28
+ # @param retention_days [Integer] archive delivered records strictly
29
+ # older than this many days (age = record updated_at)
30
+ # @param clock [#now] time source for the retention cutoff
31
+ def initialize(executor:, record_store: Molecules::DeliveryRecordStore,
32
+ deliveries_dir:, retention_days: DEFAULT_RETENTION_DAYS, clock: Time)
33
+ @executor = executor
34
+ @record_store = record_store
35
+ @deliveries_dir = deliveries_dir
36
+ @retention_days =
37
+ begin
38
+ Integer(retention_days)
39
+ rescue ArgumentError, TypeError
40
+ raise ValidationError, "tidy.delivered_retention_days must be a non-negative integer"
41
+ end
42
+ raise ValidationError, "tidy.delivered_retention_days must be a non-negative integer" if @retention_days.negative?
43
+
44
+ @clock = clock
45
+ end
46
+
47
+ # Classify cleanup candidates (and everything preserved) and, with
48
+ # apply:, perform the mutations. Returns the deterministic report
49
+ # hash the CLI renders as one JSON line.
50
+ def run(apply: false)
51
+ report = {
52
+ apply: apply,
53
+ retention_days: @retention_days,
54
+ panes: {candidates: [], preserved: [], closed: [], excluded: []},
55
+ deliveries: {candidates: [], protected: [], archived: [], preserved: []}
56
+ }
57
+
58
+ # Discovery probes everything before any mutation; a probe failure
59
+ # here (unavailable runtime) aborts with no partial report.
60
+ classify_panes!(report)
61
+ classify_deliveries!(report)
62
+ close_panes!(report) if apply
63
+ archive_deliveries!(report) if apply
64
+ report
65
+ end
66
+
67
+ private
68
+
69
+ # --- discovery ---------------------------------------------------------
70
+
71
+ def classify_panes!(report)
72
+ pane_ids = pane_rows.map { |row| row["pane_id"] }
73
+
74
+ pane_ids.each do |pane_id|
75
+ evidence = Molecules::PaneTidyProbe.pane_evidence(@executor, pane_id)
76
+ if ELIGIBLE_EVIDENCE.include?(evidence)
77
+ report[:panes][:candidates] << {id: pane_id, evidence: evidence.to_s}
78
+ else
79
+ report[:panes][:preserved] << {id: pane_id, reason: evidence.to_s}
80
+ end
81
+ end
82
+
83
+ report[:panes][:candidates].sort_by! { |entry| entry[:id].to_s }
84
+ report[:panes][:preserved].sort_by! { |entry| entry[:id].to_s }
85
+ end
86
+
87
+ def classify_deliveries!(report)
88
+ @record_store.list_records(@deliveries_dir).each do |entry|
89
+ event_id = entry[:event_id]
90
+ record = entry[:record]
91
+ if record.nil?
92
+ report[:deliveries][:preserved] << {event_id: event_id, reason: "unreadable"}
93
+ elsif record.state != "delivered"
94
+ report[:deliveries][:protected] << {event_id: event_id, state: record.state}
95
+ elsif (updated_at = parse_updated_at(record))
96
+ entry = {event_id: event_id, updated_at: record.updated_at}
97
+ if updated_at < cutoff
98
+ report[:deliveries][:candidates] << entry
99
+ else
100
+ report[:deliveries][:protected] << {event_id: event_id, state: record.state}
101
+ end
102
+ else
103
+ report[:deliveries][:preserved] << {event_id: event_id, reason: "invalid_updated_at"}
104
+ end
105
+ end
106
+
107
+ sort_delivery_entries!(report)
108
+ end
109
+
110
+ # --- apply ---------------------------------------------------------------
111
+
112
+ # Re-probe every candidate immediately before mutating: close only on
113
+ # still-positive evidence, rename -> close (vs0 close semantics)
114
+ def close_panes!(report)
115
+ report[:panes][:candidates].each do |candidate|
116
+ pane_id = candidate[:id]
117
+ evidence = Molecules::PaneTidyProbe.pane_evidence(@executor, pane_id)
118
+ unless ELIGIBLE_EVIDENCE.include?(evidence)
119
+ report[:panes][:excluded] << {id: pane_id, reason: evidence.to_s}
120
+ next
121
+ end
122
+
123
+ @executor.pane_rename(pane_id, CLOSE_LABEL)
124
+ @executor.pane_close(pane_id)
125
+ report[:panes][:closed] << {id: pane_id}
126
+ end
127
+ end
128
+
129
+ # For each candidate: reload, re-check eligibility, and archive —
130
+ # all inside the per-event lock, so a record changed by another
131
+ # writer is re-evaluated, never archived on a stale snapshot. A
132
+ # vanished or unreadable record stays in place and is reported.
133
+ def archive_deliveries!(report)
134
+ report[:deliveries][:candidates].each do |candidate|
135
+ event_id = candidate[:event_id]
136
+ outcome = nil
137
+ @record_store.with_lock(@deliveries_dir, event_id) do
138
+ fresh = @record_store.load_revalidated(@deliveries_dir, event_id)
139
+ if fresh == :unreadable
140
+ outcome = {reason: "unreadable"}
141
+ elsif fresh&.delivered? && older_than_retention?(fresh)
142
+ outcome = {archive_path: @record_store.archive(@deliveries_dir, event_id)}
143
+ end
144
+ end
145
+ if outcome&.key?(:archive_path)
146
+ report[:deliveries][:archived] << {event_id: event_id, archive_path: outcome[:archive_path]}
147
+ else
148
+ reason = outcome && outcome[:reason]
149
+ report[:deliveries][:preserved] << {event_id: event_id, reason: reason || "changed"}
150
+ end
151
+ end
152
+ end
153
+
154
+ # --- shared helpers ------------------------------------------------------
155
+
156
+ # Native list response nests rows under result.panes; anything else
157
+ # is an explicit empty state, never an error
158
+ def pane_rows
159
+ parsed = @executor.pane_list.parsed_json
160
+ rows = parsed.is_a?(Hash) && parsed["result"].is_a?(Hash) ? parsed["result"]["panes"] : nil
161
+ rows.is_a?(Array) ? rows : []
162
+ end
163
+
164
+ def cutoff
165
+ @clock.now - @retention_days * DAY_SECONDS
166
+ end
167
+
168
+ def older_than_retention?(record)
169
+ updated_at = parse_updated_at(record)
170
+ updated_at && updated_at < cutoff
171
+ end
172
+
173
+ # RFC 3339 / ISO 8601 only; anything else fails closed (nil)
174
+ def parse_updated_at(record)
175
+ Time.iso8601(record.updated_at)
176
+ rescue ArgumentError, TypeError
177
+ nil
178
+ end
179
+
180
+ def sort_delivery_entries!(report)
181
+ %i[candidates protected archived preserved].each do |bucket|
182
+ report[:deliveries][bucket].sort_by! { |entry| entry[:event_id].to_s }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Herdr
5
+ VERSION = "0.1.0"
6
+ end
7
+ end