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.
- checksums.yaml +4 -4
- data/.ace-defaults/herdr/config.yml +4 -0
- data/CHANGELOG.md +28 -0
- data/README.md +3 -0
- data/docs/usage.md +57 -2
- data/exe/ace-herdr +0 -1
- data/lib/ace/herdr/atoms/json_find.rb +46 -0
- data/lib/ace/herdr/cli/commands/inbox.rb +88 -0
- data/lib/ace/herdr/cli.rb +4 -0
- data/lib/ace/herdr/errors.rb +3 -0
- data/lib/ace/herdr/models/delivery_record.rb +42 -7
- data/lib/ace/herdr/molecules/bounded_process.rb +118 -0
- data/lib/ace/herdr/molecules/delivery_record_store.rb +3 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +96 -7
- data/lib/ace/herdr/molecules/native_queue_executor.rb +120 -0
- data/lib/ace/herdr/organisms/control_surface.rb +20 -32
- data/lib/ace/herdr/organisms/deliverer.rb +2 -0
- data/lib/ace/herdr/organisms/inbox.rb +484 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +555 -0
- data/lib/ace/herdr/organisms/tidy.rb +2 -2
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +17 -0
- data/lib/ace/runtime/adapters/herdr.rb +6 -0
- metadata +26 -5
|
@@ -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
|
data/lib/ace/herdr/version.rb
CHANGED
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
|
|