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,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Molecules
|
|
9
|
+
# Executes herdr CLI commands via argv arrays (ADR-031) and classifies
|
|
10
|
+
# failures into typed executor errors from herdr's machine-readable
|
|
11
|
+
# JSON error codes. The single seam between ace-herdr and the herdr
|
|
12
|
+
# binary; tests substitute this class.
|
|
13
|
+
class HerdrExecutor
|
|
14
|
+
DEFAULT_BINARY = "herdr"
|
|
15
|
+
|
|
16
|
+
def initialize(binary: DEFAULT_BINARY)
|
|
17
|
+
@binary = binary
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Probe the agent living in a pane (raises AgentNotFoundError when none)
|
|
21
|
+
def agent_get(pane)
|
|
22
|
+
run!([@binary, "agent", "get", pane])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Start an agent in a pane at an interactive shell prompt.
|
|
26
|
+
# Success means the agent was detected and is ready for input.
|
|
27
|
+
def agent_start(name:, kind:, pane:, timeout_ms:)
|
|
28
|
+
run!([@binary, "agent", "start", name, "--kind", kind,
|
|
29
|
+
"--pane", pane, "--timeout", timeout_ms.to_s])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Submit a prompt to the agent in a pane (push delivery). herdr
|
|
33
|
+
# rejects blocked agents pre-send with agent_blocked.
|
|
34
|
+
def agent_prompt(pane:, text:)
|
|
35
|
+
run!([@binary, "agent", "prompt", pane, text])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Wait until the agent reaches one of the requested states
|
|
39
|
+
def agent_wait(pane:, until_states:, timeout_ms:)
|
|
40
|
+
cmd = [@binary, "agent", "wait", pane]
|
|
41
|
+
until_states.each { |state| cmd += ["--until", state] }
|
|
42
|
+
cmd += ["--timeout", timeout_ms.to_s]
|
|
43
|
+
run!(cmd)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Run a shell command line in a pane (text + Enter in one call)
|
|
47
|
+
def pane_run(pane, command)
|
|
48
|
+
run!([@binary, "pane", "run", pane, command])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def pane_rename(pane, label)
|
|
52
|
+
run!([@binary, "pane", "rename", pane, label])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pane_close(pane)
|
|
56
|
+
run!([@binary, "pane", "close", pane])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pane_current
|
|
60
|
+
run!([@binary, "pane", "current", "--current"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Inspect a pane's processes (positive proof of process exit for tidy)
|
|
64
|
+
def pane_process_info(pane)
|
|
65
|
+
run!([@binary, "pane", "process-info", "--pane", pane])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Create a tab in a workspace with a label and optional cwd
|
|
69
|
+
def tab_create(workspace_id:, label:, cwd: nil, focus: nil)
|
|
70
|
+
cmd = [@binary, "tab", "create", "--workspace", workspace_id, "--label", label]
|
|
71
|
+
cmd += ["--cwd", cwd] if cwd
|
|
72
|
+
cmd += ["--focus"] if focus
|
|
73
|
+
run!(cmd)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --- terminal-control surface (spec 8wq.t.k84) -----------------------
|
|
77
|
+
|
|
78
|
+
# List workspaces (tmux sessions analogue)
|
|
79
|
+
def workspace_list
|
|
80
|
+
run!([@binary, "workspace", "list"])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# List tabs, optionally scoped to a workspace (tmux windows analogue)
|
|
84
|
+
def tab_list(workspace_id: nil)
|
|
85
|
+
cmd = [@binary, "tab", "list"]
|
|
86
|
+
cmd += ["--workspace", workspace_id] if workspace_id
|
|
87
|
+
run!(cmd)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# List panes, optionally scoped to a workspace
|
|
91
|
+
def pane_list(workspace_id: nil)
|
|
92
|
+
cmd = [@binary, "pane", "list"]
|
|
93
|
+
cmd += ["--workspace", workspace_id] if workspace_id
|
|
94
|
+
run!(cmd)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Send literal text to a pane without submitting
|
|
98
|
+
def pane_send_text(pane, text)
|
|
99
|
+
run!([@binary, "pane", "send-text", pane, text])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Send named keys to a pane, in order
|
|
103
|
+
def pane_send_keys(pane, keys)
|
|
104
|
+
run!([@binary, "pane", "send-keys", pane, *keys])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Send named keys to the agent in a pane, in order
|
|
108
|
+
def agent_send_keys(pane, keys)
|
|
109
|
+
run!([@binary, "agent", "send-keys", pane, *keys])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Read pane terminal output as raw text (capture). The raw runner
|
|
113
|
+
# keeps stdout verbatim — no trimming of leading/trailing blank lines
|
|
114
|
+
def pane_read(pane, source: "recent", lines: nil)
|
|
115
|
+
cmd = [@binary, "pane", "read", pane, "--source", source]
|
|
116
|
+
cmd += ["--lines", lines.to_s] if lines
|
|
117
|
+
run_raw(cmd)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wait for pane output containing a literal pattern. herdr checks
|
|
121
|
+
# existing content immediately, then polls; timeout fails closed.
|
|
122
|
+
def pane_wait_output(pane, pattern:, source: "recent", lines: nil, timeout_ms: nil)
|
|
123
|
+
cmd = [@binary, "pane", "wait-output", pane, "--match", pattern, "--source", source]
|
|
124
|
+
cmd += ["--lines", lines.to_s] if lines
|
|
125
|
+
cmd += ["--timeout", timeout_ms.to_s] if timeout_ms
|
|
126
|
+
run!(cmd)
|
|
127
|
+
rescue AgentNotReadyError => e
|
|
128
|
+
raise WaitTimeoutError, e.message
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Create a workspace with a label and optional cwd/focus
|
|
132
|
+
def workspace_create(label:, cwd: nil, focus: nil)
|
|
133
|
+
cmd = [@binary, "workspace", "create", "--label", label]
|
|
134
|
+
cmd += ["--cwd", cwd] if cwd
|
|
135
|
+
cmd += ["--focus"] if focus
|
|
136
|
+
run!(cmd)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Close a tab (used to drop the native initial tab of a created
|
|
140
|
+
# workspace once the preset's declared tabs exist)
|
|
141
|
+
def tab_close(tab_id)
|
|
142
|
+
run!([@binary, "tab", "close", tab_id])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Split a pane (direction: right|down); herdr reports the new pane
|
|
146
|
+
def pane_split(pane:, direction:, cwd: nil, ratio: nil, focus: nil)
|
|
147
|
+
cmd = [@binary, "pane", "split", "--pane", pane, "--direction", direction]
|
|
148
|
+
cmd += ["--cwd", cwd] if cwd
|
|
149
|
+
cmd += ["--ratio", ratio.to_s] if ratio
|
|
150
|
+
cmd += ["--focus"] if focus
|
|
151
|
+
run!(cmd)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def available?
|
|
155
|
+
run([@binary, "--version"]).success?
|
|
156
|
+
rescue ExecutorUnavailableError
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def run!(cmd)
|
|
163
|
+
result = run(cmd)
|
|
164
|
+
raise classify(result, cmd) unless result.success?
|
|
165
|
+
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Like run!, but stdout is preserved verbatim (no strip) — for
|
|
170
|
+
# commands whose output is content (pane read/capture)
|
|
171
|
+
def run_raw(cmd)
|
|
172
|
+
result = run_raw_stdout(cmd)
|
|
173
|
+
raise classify(result, cmd) unless result.success?
|
|
174
|
+
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def run(cmd)
|
|
179
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
180
|
+
ExecutionResult.new(
|
|
181
|
+
stdout: stdout.strip, stderr: stderr.strip,
|
|
182
|
+
success: status.success?, exit_code: status.exitstatus || -1
|
|
183
|
+
)
|
|
184
|
+
rescue Errno::ENOENT
|
|
185
|
+
raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def run_raw_stdout(cmd)
|
|
189
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
190
|
+
ExecutionResult.new(
|
|
191
|
+
stdout: stdout, stderr: stderr.strip,
|
|
192
|
+
success: status.success?, exit_code: status.exitstatus || -1
|
|
193
|
+
)
|
|
194
|
+
rescue Errno::ENOENT
|
|
195
|
+
raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Map a failed result to a typed error from herdr's error codes
|
|
199
|
+
# (JSON: {"error":{"code":...,"message":...}}). The native machine
|
|
200
|
+
# code is kept in the message so CLI failures carry it.
|
|
201
|
+
def classify(result, cmd)
|
|
202
|
+
code, message = error_code(result)
|
|
203
|
+
case code
|
|
204
|
+
when "agent_blocked" then AgentBlockedError.new(tag_code(code, message))
|
|
205
|
+
when "agent_prompt_stalled" then AgentNotReadyError.new(tag_code(code, message))
|
|
206
|
+
when "pane_not_found" then PaneNotFoundError.new(tag_code(code, message))
|
|
207
|
+
when "agent_not_found" then AgentNotFoundError.new(tag_code(code, message))
|
|
208
|
+
when "tab_not_found" then TabNotFoundError.new(tag_code(code, message))
|
|
209
|
+
when "workspace_not_found" then WorkspaceNotFoundError.new(tag_code(code, message))
|
|
210
|
+
when "timeout" then AgentNotReadyError.new(tag_code(code, message))
|
|
211
|
+
else
|
|
212
|
+
CommandError.new(
|
|
213
|
+
"herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} " \
|
|
214
|
+
"#{message || result.stderr}".strip
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def tag_code(code, message)
|
|
220
|
+
"#{code}: #{message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def error_code(result)
|
|
224
|
+
[result.parsed_json, parse_json(result.stderr)].each do |json|
|
|
225
|
+
next unless json.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
error = json["error"]
|
|
228
|
+
return [error["code"], error["message"]] if error.is_a?(Hash) && error["code"]
|
|
229
|
+
end
|
|
230
|
+
[nil, nil]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def parse_json(text)
|
|
234
|
+
JSON.parse(text)
|
|
235
|
+
rescue JSON::ParserError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Immutable result of a herdr command execution
|
|
241
|
+
class ExecutionResult
|
|
242
|
+
attr_reader :stdout, :stderr, :exit_code
|
|
243
|
+
|
|
244
|
+
def initialize(stdout:, stderr:, success:, exit_code:)
|
|
245
|
+
@stdout = stdout
|
|
246
|
+
@stderr = stderr
|
|
247
|
+
@success = success
|
|
248
|
+
@exit_code = exit_code
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def success?
|
|
252
|
+
@success
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Parsed stdout JSON or nil
|
|
256
|
+
def parsed_json
|
|
257
|
+
JSON.parse(@stdout)
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Herdr
|
|
5
|
+
module Molecules
|
|
6
|
+
# Positive-evidence probe for tidy (spec 8wq.t.1w0): the only component
|
|
7
|
+
# that interprets native `agent get` and `pane process-info` responses.
|
|
8
|
+
# A pane qualifies for closing solely on explicit completion proof — an
|
|
9
|
+
# observed `done` agent state or a pane with no foreground process.
|
|
10
|
+
# Every unrecognized response, missing field, or probe failure is
|
|
11
|
+
# preserve-only (no proof != dead).
|
|
12
|
+
module PaneTidyProbe
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Probe the agent in a pane and, when no agent exists, the pane's
|
|
16
|
+
# processes. Returns:
|
|
17
|
+
# :agent_done - agent observed in state done (eligible)
|
|
18
|
+
# :process_exited - pane alive, empty foreground process list (eligible)
|
|
19
|
+
# :active - live agent (idle/working/blocked) or live process
|
|
20
|
+
# :unknown - unreadable/absent evidence (preserve)
|
|
21
|
+
# :unreadable - probe failure (preserve)
|
|
22
|
+
# :gone - pane no longer exists (nothing to close)
|
|
23
|
+
def pane_evidence(executor, pane)
|
|
24
|
+
agent_evidence = agent_evidence(executor, pane)
|
|
25
|
+
return agent_evidence unless agent_evidence == :no_agent
|
|
26
|
+
|
|
27
|
+
process_evidence(executor, pane)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Interpret `agent get`: result.agent.agent_status. A missing agent
|
|
31
|
+
# (:no_agent) defers to the process probe.
|
|
32
|
+
def agent_evidence(executor, pane)
|
|
33
|
+
status = dug_value(executor.agent_get(pane).parsed_json, "result", "agent", "agent_status")
|
|
34
|
+
case status
|
|
35
|
+
when "done" then :agent_done
|
|
36
|
+
when "idle", "working", "blocked" then :active
|
|
37
|
+
else :unknown
|
|
38
|
+
end
|
|
39
|
+
rescue AgentNotFoundError
|
|
40
|
+
:no_agent
|
|
41
|
+
rescue PaneNotFoundError
|
|
42
|
+
:gone
|
|
43
|
+
rescue ExecutorError
|
|
44
|
+
:unreadable
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Interpret `pane process-info`. herdr serializes
|
|
48
|
+
# `foreground_processes` with serde skip_serializing_if (v0.9.1
|
|
49
|
+
# schema/panes.rs), so within a well-formed process_info object an
|
|
50
|
+
# OMITTED key is the process-exit proof; an explicit null or any
|
|
51
|
+
# non-array value is malformed evidence and preserves. Anything
|
|
52
|
+
# alive appears as a non-empty array.
|
|
53
|
+
def process_evidence(executor, pane)
|
|
54
|
+
info = dug_value(executor.pane_process_info(pane).parsed_json, "result", "process_info")
|
|
55
|
+
return :unknown unless info.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
processes = info["foreground_processes"]
|
|
58
|
+
return :unknown if info.key?("foreground_processes") && !processes.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
processes.to_a.empty? ? :process_exited : :active
|
|
61
|
+
rescue PaneNotFoundError
|
|
62
|
+
:gone
|
|
63
|
+
rescue ExecutorError
|
|
64
|
+
:unreadable
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Path walk that fails closed: nil unless every hop is a Hash
|
|
68
|
+
def dug_value(parsed, *path)
|
|
69
|
+
current = parsed
|
|
70
|
+
path.each do |key|
|
|
71
|
+
return nil unless current.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
current = current[key]
|
|
74
|
+
end
|
|
75
|
+
current
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "ace/support/config"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Molecules
|
|
9
|
+
# Finds and loads herdr workspace/tab YAML presets across the ADR-022
|
|
10
|
+
# config cascade:
|
|
11
|
+
# 1. project .ace/herdr/ (highest priority)
|
|
12
|
+
# 2. user ~/.ace/herdr/
|
|
13
|
+
# 3. gem .ace-defaults/herdr/ (lowest priority)
|
|
14
|
+
class PresetLoader
|
|
15
|
+
PRESET_TYPES = %w[workspaces tabs].freeze
|
|
16
|
+
|
|
17
|
+
# @param gem_root [String] Gem root directory for defaults
|
|
18
|
+
# @param start_path [String, nil] Starting path for cascade traversal
|
|
19
|
+
def initialize(gem_root: Ace::Herdr.gem_root, start_path: nil)
|
|
20
|
+
@resolver = Ace::Support::Config.virtual_resolver(
|
|
21
|
+
config_dir: ".ace",
|
|
22
|
+
defaults_dir: ".ace-defaults",
|
|
23
|
+
start_path: start_path,
|
|
24
|
+
gem_path: gem_root
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Load a preset by type and name
|
|
29
|
+
#
|
|
30
|
+
# @param type [String] Preset type: "workspaces" or "tabs"
|
|
31
|
+
# @param name [String] Preset name (without .yml extension)
|
|
32
|
+
# @return [Hash, nil] Parsed YAML hash, or nil when unknown
|
|
33
|
+
def load(type, name)
|
|
34
|
+
absolute_path = @resolver.resolve_path("herdr/#{type}/#{name}.yml")
|
|
35
|
+
return nil unless absolute_path && File.exist?(absolute_path)
|
|
36
|
+
|
|
37
|
+
YAML.safe_load_file(absolute_path, permitted_classes: [Date], aliases: true) || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# List available preset names for a type, merged across the cascade
|
|
41
|
+
#
|
|
42
|
+
# @param type [String] Preset type: "workspaces" or "tabs"
|
|
43
|
+
# @return [Array<String>] Preset names (without .yml extension)
|
|
44
|
+
def list(type)
|
|
45
|
+
@resolver.glob("herdr/#{type}/*.yml").keys
|
|
46
|
+
.map { |relative_path| File.basename(relative_path, ".yml") }
|
|
47
|
+
.sort.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# List all preset types and their presets
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash<String, Array<String>>] Map of type => preset names
|
|
53
|
+
def list_all
|
|
54
|
+
PRESET_TYPES.each_with_object({}) do |type, result|
|
|
55
|
+
presets = list(type)
|
|
56
|
+
result[type] = presets unless presets.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create a lookup proc for PresetResolver
|
|
61
|
+
#
|
|
62
|
+
# @param type [String] Preset type to look up
|
|
63
|
+
# @return [Proc] Proc that takes a name and returns a preset hash
|
|
64
|
+
def to_lookup(type)
|
|
65
|
+
->(name) { load(type, name) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/config"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Herdr
|
|
7
|
+
module Molecules
|
|
8
|
+
# Resolves `preset:` references in herdr workspace/tab presets
|
|
9
|
+
# (spec 8wq.t.k84): recursive lookup with deep merge (overlay wins),
|
|
10
|
+
# array concat so collections compose, cycle detection, and
|
|
11
|
+
# fail-closed missing references.
|
|
12
|
+
module PresetResolver
|
|
13
|
+
MAX_DEPTH = 10
|
|
14
|
+
|
|
15
|
+
# Raised when a preset references an unknown nested preset
|
|
16
|
+
class PresetNotFoundError < Ace::Herdr::Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when preset references form a cycle
|
|
19
|
+
class CircularPresetError < Ace::Herdr::Error; end
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Resolve a workspace hash: the root may inherit another workspace
|
|
24
|
+
# preset; each tab entry may inherit a tab preset.
|
|
25
|
+
#
|
|
26
|
+
# @param hash [Hash] Workspace configuration hash
|
|
27
|
+
# @param workspace_lookup [Proc] name => workspace preset hash
|
|
28
|
+
# @param tab_lookup [Proc] name => tab preset hash
|
|
29
|
+
# @return [Hash] Fully resolved workspace hash
|
|
30
|
+
def resolve_workspace(hash, workspace_lookup:, tab_lookup:)
|
|
31
|
+
resolved = resolve_refs(hash, lookup: workspace_lookup)
|
|
32
|
+
tabs = resolved["tabs"]
|
|
33
|
+
if tabs.is_a?(Array)
|
|
34
|
+
resolved = resolved.merge(
|
|
35
|
+
"tabs" => tabs.map { |entry| resolve_tab(tab_entry(entry), tab_lookup: tab_lookup) }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
resolved
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a tab hash: it may inherit another tab preset; pane string
|
|
42
|
+
# shorthand becomes a command entry.
|
|
43
|
+
#
|
|
44
|
+
# @param hash [Hash, String] Tab configuration hash (or label shorthand)
|
|
45
|
+
# @param tab_lookup [Proc] name => tab preset hash
|
|
46
|
+
# @return [Hash] Resolved tab hash
|
|
47
|
+
def resolve_tab(hash, tab_lookup:)
|
|
48
|
+
resolved = resolve_refs(tab_entry(hash), lookup: tab_lookup)
|
|
49
|
+
panes = resolved["panes"]
|
|
50
|
+
if panes.is_a?(Array)
|
|
51
|
+
resolved = resolved.merge("panes" => panes.map { |entry| pane_entry(entry) })
|
|
52
|
+
end
|
|
53
|
+
resolved
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolve one `preset:` reference chain onto `hash`
|
|
57
|
+
#
|
|
58
|
+
# @param hash [Hash] Hash that may contain a "preset" key
|
|
59
|
+
# @param lookup [Proc] name => preset hash (nil when unknown)
|
|
60
|
+
# @param depth [Integer] Recursion depth (cycle guard)
|
|
61
|
+
# @return [Hash] Resolved hash with the base deep-merged underneath
|
|
62
|
+
def resolve_refs(hash, lookup:, depth: 0)
|
|
63
|
+
raise CircularPresetError, "Preset resolution exceeded max depth (#{MAX_DEPTH})" if depth >= MAX_DEPTH
|
|
64
|
+
return hash unless hash.is_a?(Hash) && hash.key?("preset")
|
|
65
|
+
|
|
66
|
+
name = hash["preset"].to_s
|
|
67
|
+
base = lookup.call(name)
|
|
68
|
+
raise PresetNotFoundError, "Unknown preset '#{name}'" if base.nil?
|
|
69
|
+
|
|
70
|
+
base = resolve_refs(base, lookup: lookup, depth: depth + 1)
|
|
71
|
+
overlay = hash.reject { |key, _| key == "preset" }
|
|
72
|
+
Ace::Support::Config::Atoms::DeepMerger.merge(base, overlay, array_strategy: :concat)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @api private
|
|
76
|
+
def tab_entry(entry)
|
|
77
|
+
entry.is_a?(Hash) ? entry : {"label" => entry.to_s}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @api private
|
|
81
|
+
# Pane string shorthand declares a command to run in a new split
|
|
82
|
+
def pane_entry(entry)
|
|
83
|
+
entry.is_a?(Hash) ? entry : {"command" => entry.to_s}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|