ace-herdr 0.1.0 → 0.3.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.
@@ -0,0 +1,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Organisms
11
+ # Maps ace-runtime's terminal intents onto herdr's workspace/tab/pane API.
12
+ # Contract session means workspace; contract window means tab.
13
+ class RuntimeAdapter
14
+ POLL_INTERVAL = 0.02
15
+
16
+ def initialize(executor: Molecules::HerdrExecutor.new, env: ENV, clock: Process, sleeper: Kernel, surface: nil,
17
+ identity_dir: nil, poll_interval: POLL_INTERVAL)
18
+ @executor = executor
19
+ @env = env
20
+ @clock = clock
21
+ @sleeper = sleeper
22
+ @surface = surface || ControlSurface.new(executor: executor)
23
+ @prepared = {}
24
+ @identity_dir = identity_dir
25
+ @poll_interval = poll_interval
26
+ end
27
+
28
+ def context
29
+ return {in_runtime: false, session: nil, window: nil, pane: nil} unless inside?
30
+
31
+ with_errors do
32
+ current = @executor.pane_get(@env["HERDR_PANE"]).parsed_json
33
+ native_workspace = find_value(current, %w[workspace_id workspaceId])
34
+ hint = @env["HERDR_WORKSPACE_ID"]
35
+ if hint && native_workspace && hint != native_workspace
36
+ raise Runtime::RuntimeUnavailableError,
37
+ "HERDR_WORKSPACE_ID '#{hint}' does not match the caller pane's workspace " \
38
+ "'#{native_workspace}'; clear the hint or reconnect the pane"
39
+ end
40
+
41
+ {
42
+ in_runtime: true,
43
+ session: native_workspace || hint,
44
+ window: find_value(current, %w[tab_id tabId]),
45
+ pane: @env["HERDR_PANE"] || find_value(current, %w[pane_id paneId])
46
+ }
47
+ end
48
+ end
49
+
50
+ def ensure_window(name:, root:, preset: nil)
51
+ with_errors do
52
+ workspace = workspace_id!
53
+ label = Runtime.sanitize_name(name)
54
+ with_identity_lock(workspace, label) do
55
+ path = identity_path(workspace, label)
56
+ existing = tabs(workspace).find { |tab| tab[:name] == label }
57
+ if existing
58
+ # Ownership must be proven by the record (or, without one,
59
+ # by the pane's native root for a preset-free request); a
60
+ # tab we cannot verify is never closed or replaced.
61
+ unless verifiable_identity?(existing, root: root, preset: preset, path: path)
62
+ raise Runtime::WindowConflictError,
63
+ "window '#{existing[:name]}' conflicts with root or preset"
64
+ end
65
+
66
+ # Adopting a record-less tab: persist its provenance (and
67
+ # keep any prepared-pane pointer) so later instances share it.
68
+ if read_identity(path, existing).nil?
69
+ write_identity(path, id: existing[:id], root: canonical_root(root), preset: preset,
70
+ prepared_pane: pointer_prepared_pane(path, existing[:id]))
71
+ end
72
+ next existing[:id]
73
+ end
74
+
75
+ created_id =
76
+ if preset
77
+ begin
78
+ created = @surface.create_tab(preset, workspace_id: workspace, cwd: File.expand_path(root), label: label)
79
+ rescue TabMaterializationError => e
80
+ # Roll back the tab this call created — exact id from
81
+ # the surface, never a listing guess. Best effort: a
82
+ # rollback failure never replaces the primary error.
83
+ begin
84
+ @executor.tab_close(e.tab_id)
85
+ rescue StandardError => close_error
86
+ warn "ace-herdr: rollback close failed for tab #{e.tab_id}: #{close_error.class}: #{close_error.message}"
87
+ end
88
+ raise
89
+ end
90
+ created.fetch(:tab)
91
+ else
92
+ parsed = @executor.tab_create(workspace_id: workspace, label: label, cwd: File.expand_path(root)).parsed_json
93
+ find_value(parsed, %w[tab tab_id])
94
+ end
95
+ raise TargetResolutionError, "tab create returned no tab id" if created_id.nil?
96
+
97
+ begin
98
+ write_identity(path, id: created_id, root: canonical_root(root), preset: preset)
99
+ rescue StandardError
100
+ begin
101
+ @executor.tab_close(created_id)
102
+ rescue StandardError => close_error
103
+ warn "ace-herdr: rollback close failed for tab #{created_id}: #{close_error.class}: #{close_error.message}"
104
+ end
105
+ raise
106
+ end
107
+ created_id
108
+ end
109
+ end
110
+ end
111
+
112
+ def prepare_pane(window:)
113
+ with_errors do
114
+ tab = resolve_tab!(window)
115
+ # Hold the identity lock through discovery, split, verification,
116
+ # and recording so concurrent instances cannot split twice.
117
+ with_identity_lock(tab[:workspace], tab[:name]) do
118
+ cached = discover_prepared_pane(tab)
119
+ next cached if cached
120
+
121
+ # Split a shell pane: prefer the first pane without a live
122
+ # agent — pane list ordering does not contractually promise
123
+ # a shell root pane in foreign multi-pane tabs. Single-pane
124
+ # tabs skip the probe entirely.
125
+ tab_panes = panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
126
+ root_pane = tab_panes.first
127
+ if tab_panes.length > 1
128
+ root_pane = tab_panes.find { |pane| !agent_pane?(pane[:pane]) } || tab_panes.first
129
+ end
130
+ raise Runtime::TargetNotFoundError, "tab '#{window}' has no pane" unless root_pane
131
+
132
+ parsed = @executor.pane_split(pane: root_pane[:pane], direction: "right").parsed_json
133
+ id = find_value(parsed, %w[pane pane_id])
134
+ raise TargetResolutionError, "pane split returned no pane id" if id.nil?
135
+
136
+ verify_retained_shell!(id)
137
+ record_prepared_pane(tab, id)
138
+ id
139
+ end
140
+ end
141
+ end
142
+
143
+ def focus(window:)
144
+ with_errors { @executor.tab_focus(resolve_tab!(window)[:id]); true }
145
+ end
146
+
147
+ def send_profile
148
+ :agent_aware
149
+ end
150
+
151
+ def send(pane:, command: nil, items: [])
152
+ # Reject malformed shared shapes before any delivery or mutation;
153
+ # the agent probe below is a native read, never a write.
154
+ Runtime::Atoms::SendContract.normalize!(command: command, items: items, profile: :plain_pane)
155
+ with_errors do
156
+ agent = agent_pane?(pane)
157
+ request = Runtime::Atoms::SendContract.normalize!(
158
+ command: command, items: items, profile: agent ? :agent_aware : :plain_pane
159
+ )
160
+ if agent
161
+ request.items.each do |item|
162
+ item.key?(:message) ? @executor.agent_prompt(pane: pane, text: item[:message]) :
163
+ @executor.agent_send_keys(pane, [item[:key]])
164
+ end
165
+ else
166
+ @executor.pane_run(pane, request.command) if request.command
167
+ request.items.each do |item|
168
+ item.key?(:message) ? @executor.pane_send_text(pane, item[:message]) :
169
+ @executor.pane_send_keys(pane, [item[:key]])
170
+ end
171
+ end
172
+ Runtime::Atoms::SendContract::Result.new(dropped_trailing_enter: request.dropped_trailing_enter)
173
+ end
174
+ end
175
+
176
+ def send_command(pane:, command:)
177
+ send(pane: pane, command: command)
178
+ end
179
+
180
+ def send_text(pane:, text:)
181
+ send(pane: pane, items: [{message: text}])
182
+ end
183
+
184
+ def send_keys(pane:, keys:)
185
+ send(pane: pane, items: Array(keys).map { |key| {key: key} })
186
+ end
187
+
188
+ def capture(pane:, lines: 40)
189
+ with_errors { @surface.capture(pane: pane, lines: lines) }
190
+ end
191
+
192
+ def wait_output(pane:, pattern:, timeout:)
193
+ with_errors(condition: "output", target: pane, timeout: timeout) do
194
+ @surface.wait_output(pane: pane, pattern: pattern, timeout_ms: milliseconds(timeout))
195
+ end
196
+ end
197
+
198
+ def wait_agent(pane:, states:, timeout:)
199
+ with_errors(condition: "agent", target: pane, timeout: timeout) do
200
+ @executor.agent_wait(pane: pane, until_states: states, timeout_ms: milliseconds(timeout))
201
+ true
202
+ end
203
+ end
204
+
205
+ def wait_lifecycle(condition:, target:, timeout:)
206
+ unless Runtime::Atoms::SendContract::LIFECYCLE_CONDITIONS.include?(condition)
207
+ raise ArgumentError, "unknown lifecycle condition '#{condition}'"
208
+ end
209
+
210
+ # Window conditions resolve the caller workspace once per wait so
211
+ # poll iterations do not re-run context and tab-list subprocesses;
212
+ # pane conditions never need it.
213
+ scope = nil
214
+ deadline = @clock.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
215
+ with_errors(condition: condition, target: target, timeout: timeout) do
216
+ loop do
217
+ if condition.start_with?("window-") && scope.nil?
218
+ scope = workspace_id!
219
+ end
220
+ return true if lifecycle_met?(condition, target, workspace: scope)
221
+ raise Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) if
222
+ @clock.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
223
+
224
+ @sleeper.sleep(@poll_interval)
225
+ end
226
+ end
227
+ end
228
+
229
+ def close_window(window:)
230
+ with_errors { @executor.tab_close(resolve_tab!(window)[:id]); true }
231
+ end
232
+
233
+ def list_windows
234
+ with_errors { tabs(workspace_id!).map { |tab| {name: tab[:name], window: tab[:id], active: tab[:active]} } }
235
+ end
236
+
237
+ def list_panes(window:)
238
+ with_errors do
239
+ tab = resolve_tab!(window)
240
+ panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def inside?
247
+ !@env["HERDR_SESSION"].to_s.empty? && !@env["HERDR_PANE"].to_s.empty?
248
+ end
249
+
250
+ def workspace_id!
251
+ raise Runtime::RuntimeUnavailableError, "not inside a herdr workspace" unless inside?
252
+
253
+ context[:session] || raise(Runtime::RuntimeUnavailableError, "herdr workspace is unavailable")
254
+ end
255
+
256
+ def tabs(workspace)
257
+ @surface.list_tabs(workspace_id: workspace).map do |row|
258
+ {id: row[:id], name: row[:title], workspace: row[:workspace], active: row[:focused]}
259
+ end
260
+ end
261
+
262
+ def panes(workspace)
263
+ @surface.list_panes(workspace_id: workspace).map do |row|
264
+ {pane: row[:id], tab: row[:tab], workspace: row[:workspace]}
265
+ end
266
+ end
267
+
268
+ def resolve_tab!(window)
269
+ workspace = workspace_id!
270
+ tab = tabs(workspace).find { |row| row[:id] == window || row[:name] == window }
271
+ tab || raise(Runtime::TargetNotFoundError, "tab '#{window}' not found")
272
+ end
273
+
274
+ def canonical_root(root)
275
+ File.realpath(root)
276
+ rescue Errno::ENOENT
277
+ File.expand_path(root)
278
+ end
279
+
280
+ # Identity records and locks are keyed on the shared (workspace,
281
+ # label) identity in a stable user-level location — instances from
282
+ # any cwd and any requested root agree on the same record, so
283
+ # concurrent creators of the same label serialize and reuse is
284
+ # verifiable across processes.
285
+ def identity_path(workspace, label)
286
+ base = @identity_dir || File.join(Dir.home, ".ace", "local", "herdr", "runtime-tabs")
287
+ File.join(base, "#{Digest::SHA256.hexdigest("#{workspace}\0#{label}")}.json")
288
+ end
289
+
290
+ def with_identity_lock(workspace, label, &_block)
291
+ path = "#{identity_path(workspace, label)}.lock"
292
+ FileUtils.mkdir_p(File.dirname(path))
293
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
294
+ file.flock(File::LOCK_EX)
295
+ yield
296
+ end
297
+ end
298
+
299
+ # Reuse needs the recorded identity to match the request; when no
300
+ # readable record exists, native pane cwd is the only remaining
301
+ # root evidence and a preset can never be verified.
302
+ def verifiable_identity?(tab, root:, preset:, path:)
303
+ recorded = read_identity(path, tab)
304
+ if recorded
305
+ return recorded["root"] == canonical_root(root) && recorded["preset"] == preset
306
+ end
307
+
308
+ native_root = native_pane_cwd(tab)
309
+ native_root && canonical_root(native_root) == canonical_root(root) && preset.nil?
310
+ end
311
+
312
+ # Rollback after a failed create is exact-id only (see ensure_window);
313
+ # listings are never consulted for ownership decisions.
314
+
315
+ def read_identity(path, tab)
316
+ recorded = JSON.parse(File.read(path)) if File.file?(path)
317
+ recorded = nil unless recorded.is_a?(Hash) && recorded["id"] == tab[:id]
318
+ # A pointer-only record (no root/preset provenance — written for
319
+ # a foreign tab's prepared pane) proves no ownership.
320
+ recorded = nil if recorded && !recorded.key?("root") && !recorded.key?("preset")
321
+ recorded
322
+ rescue JSON::ParserError
323
+ raise Runtime::WindowConflictError,
324
+ "window '#{tab[:name]}' has unreadable root or preset identity"
325
+ end
326
+
327
+ def native_pane_cwd(tab)
328
+ native_pane = @surface.list_panes(workspace_id: tab[:workspace]).find { |pane| pane[:tab] == tab[:id] }
329
+ native_pane && native_pane[:cwd]
330
+ end
331
+
332
+ # Cross-instance prepared-pane reuse: the identity record carries
333
+ # the prepared pane id, so another adapter instance (or process)
334
+ # reuses the retained target instead of splitting again. Works for
335
+ # provenance records and pointer-only (foreign tab) records alike.
336
+ def discover_prepared_pane(tab)
337
+ cached = @prepared[tab[:id]]
338
+ return cached if usable_prepared_pane?(cached)
339
+
340
+ pane_id = pointer_prepared_pane(identity_path(tab[:workspace], tab[:name]), tab[:id])
341
+ return nil unless pane_id && usable_prepared_pane?(pane_id)
342
+
343
+ @prepared[tab[:id]] = pane_id
344
+ pane_id
345
+ end
346
+
347
+ def pointer_prepared_pane(path, id)
348
+ raw = JSON.parse(File.read(path))
349
+ raw.is_a?(Hash) && raw["id"] == id ? raw["prepared_pane"] : nil
350
+ rescue JSON::ParserError, Errno::ENOENT
351
+ nil
352
+ end
353
+
354
+ def record_prepared_pane(tab, pane_id)
355
+ @prepared[tab[:id]] = pane_id
356
+ path = identity_path(tab[:workspace], tab[:name])
357
+ begin
358
+ recorded = File.file?(path) ? JSON.parse(File.read(path)) : nil
359
+ rescue JSON::ParserError
360
+ # A corrupt record is repaired under the identity lock rather
361
+ # than silently dropped, so the pointer survives.
362
+ recorded = :corrupt
363
+ end
364
+ if recorded.is_a?(Hash) && recorded["id"] == tab[:id]
365
+ write_identity(path, id: recorded["id"], root: recorded["root"], preset: recorded["preset"],
366
+ prepared_pane: pane_id)
367
+ else
368
+ # Foreign tab (not created through ensure_window) or corrupt
369
+ # record: persist a pointer-only record so later instances
370
+ # reuse this pane instead of splitting again.
371
+ write_pointer_only(path, id: tab[:id], prepared_pane: pane_id)
372
+ end
373
+ rescue StandardError => e
374
+ # The pointer is an optimization; losing it only costs a re-split.
375
+ # Warn and continue — provenance writes in ensure_window stay
376
+ # hard-fail, this one does not.
377
+ warn "ace-herdr: could not persist prepared-pane record for '#{tab[:name]}': #{e.class}: #{e.message}"
378
+ nil
379
+ end
380
+
381
+ def usable_prepared_pane?(pane_id)
382
+ return false unless pane_id
383
+
384
+ @executor.pane_get(pane_id)
385
+ info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
386
+ info.is_a?(Hash) && !!find_value_of_type(info, "shell_pid", Integer)
387
+ rescue PaneNotFoundError
388
+ false
389
+ end
390
+
391
+ def verify_retained_shell!(pane_id)
392
+ info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
393
+ return if info.is_a?(Hash) && find_value_of_type(info, "shell_pid", Integer)
394
+
395
+ raise Runtime::Error, "prepared pane '#{pane_id}' has no retained shell"
396
+ end
397
+
398
+ def write_identity(path, id:, root:, preset:, prepared_pane: nil)
399
+ payload = {id: id, root: root, preset: preset}
400
+ payload[:prepared_pane] = prepared_pane if prepared_pane
401
+ atomic_write_json(path, payload)
402
+ end
403
+
404
+ def write_pointer_only(path, id:, prepared_pane:)
405
+ atomic_write_json(path, {id: id, prepared_pane: prepared_pane})
406
+ end
407
+
408
+ def atomic_write_json(path, payload)
409
+ temp = "#{path}.#{Process.pid}.tmp"
410
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
411
+ file.write(JSON.generate(payload))
412
+ file.flush
413
+ file.fsync
414
+ end
415
+ File.rename(temp, path)
416
+ ensure
417
+ File.delete(temp) if temp && File.exist?(temp)
418
+ end
419
+
420
+ def agent_pane?(pane)
421
+ @executor.agent_get(pane)
422
+ true
423
+ rescue AgentNotFoundError
424
+ false
425
+ end
426
+
427
+ def lifecycle_met?(condition, target, workspace:)
428
+ case condition
429
+ when "window-exists" then tab_exists?(target, workspace: workspace)
430
+ when "window-active" then tab_active?(target, workspace: workspace)
431
+ when "pane-exists" then pane_exists?(target)
432
+ when "pane-exited" then pane_exited?(target)
433
+ end
434
+ end
435
+
436
+ def tab_exists?(target, workspace:)
437
+ @executor.tab_get(find_tab(target, workspace)[:id])
438
+ true
439
+ rescue Runtime::TargetNotFoundError, TabNotFoundError
440
+ false
441
+ end
442
+
443
+ def tab_active?(target, workspace:)
444
+ tab = find_tab(target, workspace)
445
+ @executor.tab_get(tab[:id])
446
+ tab[:active] == true
447
+ rescue Runtime::TargetNotFoundError, TabNotFoundError
448
+ false
449
+ end
450
+
451
+ # Scoped to the workspace resolved once for the enclosing wait.
452
+ def find_tab(target, workspace)
453
+ tabs(workspace).find { |row| row[:id] == target || row[:name] == target } ||
454
+ raise(Runtime::TargetNotFoundError, "tab '#{target}' not found")
455
+ end
456
+
457
+ def pane_exists?(target)
458
+ @executor.pane_get(target)
459
+ true
460
+ rescue PaneNotFoundError
461
+ false
462
+ end
463
+
464
+ # Mirrors PaneTidyProbe.process_evidence semantics: herdr
465
+ # serializes `foreground_processes` with serde skip_serializing_if,
466
+ # so inside a well-formed result.process_info object an OMITTED key
467
+ # is the process-exit proof; an explicit null or non-array value is
468
+ # malformed evidence and stays inconclusive. Anything alive appears
469
+ # as a non-empty array; a list holding only the retained shell
470
+ # counts as exited (no submitted foreground command remains).
471
+ def pane_exited?(target)
472
+ return true unless pane_exists?(target)
473
+
474
+ info = find_hash(@executor.pane_process_info(target).parsed_json, %w[result process_info])
475
+ return false unless info.is_a?(Hash)
476
+
477
+ processes = info["foreground_processes"]
478
+ return false if info.key?("foreground_processes") && !processes.is_a?(Array)
479
+
480
+ # An omitted list is only trusted exit evidence inside a
481
+ # well-formed object that also carries the pane's shell pid;
482
+ # an empty or shell-only hash alone proves nothing.
483
+ shell_pid = find_value_of_type(info, "shell_pid", Integer)
484
+ return false unless shell_pid
485
+
486
+ processes = Array(processes)
487
+ return true if processes.empty?
488
+
489
+ processes.all? { |process| process.is_a?(Hash) && process["pid"] == shell_pid }
490
+ rescue PaneNotFoundError
491
+ true
492
+ end
493
+
494
+ def find_value(value, keys)
495
+ Atoms::JsonFind.find_value(value, keys)
496
+ end
497
+
498
+ # Pinned-path walk that fails closed: nil unless every hop resolves
499
+ def find_hash(value, keys)
500
+ keys.each do |key|
501
+ return nil unless value.is_a?(Hash)
502
+
503
+ value = value[key]
504
+ end
505
+ value
506
+ end
507
+
508
+ def find_value_of_type(value, key, type)
509
+ return unless value.is_a?(Hash)
510
+ return value[key] if value[key].is_a?(type)
511
+
512
+ value.each_value { |child| found = find_value_of_type(child, key, type); return found if found }
513
+ nil
514
+ end
515
+
516
+ def milliseconds(seconds)
517
+ (seconds * 1000).ceil
518
+ end
519
+
520
+ def with_errors(condition: nil, target: nil, timeout: nil)
521
+ yield
522
+ rescue AgentBlockedError => e
523
+ raise Runtime::SendRejectedError, e.message
524
+ rescue ExecutorTimeoutError, Herdr::WaitTimeoutError => e
525
+ raise Runtime::WaitTimeoutError.new(condition: condition || "send", target: target, timeout: timeout || 0)
526
+ rescue AgentNotReadyError => e
527
+ error = condition ? Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) :
528
+ Runtime::SendStalledError.new(e.message)
529
+ raise error
530
+ rescue PaneNotFoundError, TabNotFoundError, AgentNotFoundError => e
531
+ raise Runtime::TargetNotFoundError, e.message
532
+ rescue WorkspaceNotFoundError, TargetResolutionError => e
533
+ # Unresolvable caller context, or the runtime responded but the
534
+ # answer was unusable (missing ids)
535
+ raise Runtime::RuntimeUnavailableError, e.message
536
+ rescue TabMaterializationError, ValidationError, CommandError => e
537
+ # Bad request configuration, a failed materialization, or an
538
+ # unmapped native failure keeps the native cause while surfacing
539
+ # under the contract error type.
540
+ raise Runtime::Error, e.message
541
+ rescue ExecutorUnavailableError => e
542
+ raise Runtime::RuntimeUnavailableError, e.message
543
+ rescue Runtime::Error
544
+ # Already a contract error (raised inside the block): pass through
545
+ # untouched — never re-wrap.
546
+ raise
547
+ rescue StandardError => e
548
+ # Adapter boundary: every runtime failure surfaces under the
549
+ # contract error model, with the native cause preserved.
550
+ raise Runtime::Error, "#{e.class}: #{e.message}"
551
+ end
552
+ end
553
+ end
554
+ end
555
+ end
@@ -90,7 +90,7 @@ module Ace
90
90
  record = entry[:record]
91
91
  if record.nil?
92
92
  report[:deliveries][:preserved] << {event_id: event_id, reason: "unreadable"}
93
- elsif record.state != "delivered"
93
+ elsif record.inbox || record.state != "delivered"
94
94
  report[:deliveries][:protected] << {event_id: event_id, state: record.state}
95
95
  elsif (updated_at = parse_updated_at(record))
96
96
  entry = {event_id: event_id, updated_at: record.updated_at}
@@ -138,7 +138,7 @@ module Ace
138
138
  fresh = @record_store.load_revalidated(@deliveries_dir, event_id)
139
139
  if fresh == :unreadable
140
140
  outcome = {reason: "unreadable"}
141
- elsif fresh&.delivered? && older_than_retention?(fresh)
141
+ elsif fresh&.delivered? && !fresh.inbox && older_than_retention?(fresh)
142
142
  outcome = {archive_path: @record_store.archive(@deliveries_dir, event_id)}
143
143
  end
144
144
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Herdr
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/ace/herdr.rb CHANGED
@@ -15,6 +15,18 @@ module Ace
15
15
 
16
16
  # Raised when a bounded wait exceeds its timeout
17
17
  class WaitTimeoutError < Error; end
18
+
19
+ # Raised when a preset tab was created natively but a later
20
+ # materialization step (splits, commands, agents) failed; carries the
21
+ # created tab id so the owner can roll back exactly that tab.
22
+ class TabMaterializationError < Error
23
+ attr_reader :tab_id
24
+
25
+ def initialize(tab_id:, message:)
26
+ @tab_id = tab_id
27
+ super(message)
28
+ end
29
+ end
18
30
  end
19
31
  end
20
32
 
@@ -23,16 +35,21 @@ require_relative "herdr/errors"
23
35
 
24
36
  # Load all ace-herdr components
25
37
  require_relative "herdr/atoms/answer_digest"
38
+ require_relative "herdr/atoms/json_find"
26
39
  require_relative "herdr/models/delivery_record"
27
40
  require_relative "herdr/models/dispatch_outcome"
41
+ require_relative "herdr/molecules/bounded_process"
28
42
  require_relative "herdr/molecules/herdr_executor"
43
+ require_relative "herdr/molecules/native_queue_executor"
29
44
  require_relative "herdr/molecules/pane_tidy_probe"
30
45
  require_relative "herdr/molecules/delivery_record_store"
31
46
  require_relative "herdr/molecules/preset_loader"
32
47
  require_relative "herdr/molecules/preset_resolver"
33
48
  require_relative "herdr/organisms/deliverer"
49
+ require_relative "herdr/organisms/inbox"
34
50
  require_relative "herdr/organisms/dispatcher"
35
51
  require_relative "herdr/organisms/control_surface"
52
+ require_relative "herdr/organisms/runtime_adapter"
36
53
  require_relative "herdr/organisms/tidy"
37
54
  require_relative "herdr/cli"
38
55
 
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr"
4
+ require "ace/runtime"
5
+
6
+ Ace::Runtime.register(:herdr, -> { Ace::Herdr::Organisms::RuntimeAdapter.new })