ace-runtime 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/runtime/config.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +12 -0
- data/docs/usage.md +189 -0
- data/exe/ace-runtime +17 -0
- data/lib/ace/runtime/atoms/detector.rb +34 -0
- data/lib/ace/runtime/atoms/name_sanitizer.rb +34 -0
- data/lib/ace/runtime/atoms/send_contract.rb +160 -0
- data/lib/ace/runtime/cli/commands/send.rb +120 -0
- data/lib/ace/runtime/cli.rb +69 -0
- data/lib/ace/runtime/errors.rb +60 -0
- data/lib/ace/runtime/molecules/runtime_selector.rb +73 -0
- data/lib/ace/runtime/registry.rb +68 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +597 -0
- data/lib/ace/runtime/testing/scripted_runtime.rb +224 -0
- data/lib/ace/runtime/testing.rb +9 -0
- data/lib/ace/runtime/version.rb +7 -0
- data/lib/ace/runtime.rb +102 -0
- metadata +194 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "ace/core"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Runtime
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# The neutral callback passthrough (Fork Callback Rule). Resolves
|
|
11
|
+
# the configured runtime and delegates to runtime.send with the
|
|
12
|
+
# normative send-matrix semantics: ordered delivery on plain
|
|
13
|
+
# panes, single self-submitting prompt shapes on agent panes.
|
|
14
|
+
# Rejected input fails before any transport call.
|
|
15
|
+
class Send < Ace::Support::Cli::Command
|
|
16
|
+
include Ace::Support::Cli::Base
|
|
17
|
+
|
|
18
|
+
desc <<~DESC.strip
|
|
19
|
+
Send a submitted command, text chunks, or named keys to a pane via the resolved terminal runtime
|
|
20
|
+
|
|
21
|
+
Runtime resolution: --runtime > ACE_RUNTIME > configured runtime > auto-detection
|
|
22
|
+
(tmux wins when both environments are live). Rejected sends make no transport call.
|
|
23
|
+
DESC
|
|
24
|
+
|
|
25
|
+
example [
|
|
26
|
+
"--pane %1 --cmd 'bundle exec rake test'",
|
|
27
|
+
"--pane %1 --msg 'Reply with exactly: pong' --key Enter",
|
|
28
|
+
"--runtime herdr --pane w1:p1 --msg 'hello'",
|
|
29
|
+
"--pane %1 --key C-c"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
option :runtime, type: :string, desc: "Terminal runtime name (tmux, herdr); overrides ACE_RUNTIME, config, and detection"
|
|
33
|
+
option :pane, type: :string, required: true, desc: "Target pane handle as exposed by the runtime (opaque, adapter-owned)"
|
|
34
|
+
option :cmd, type: :string, aliases: %w[-c], desc: "Command text to submit once (declare before any --key)"
|
|
35
|
+
option :msg, type: :string, aliases: %w[-m], repeat: true, desc: "Literal text chunk to deliver without implicit submission"
|
|
36
|
+
option :key, type: :string, aliases: %w[-k], repeat: true, desc: "Named key to deliver (for example Enter, C-c)"
|
|
37
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
38
|
+
|
|
39
|
+
def call(**options)
|
|
40
|
+
command, items = build_request(options)
|
|
41
|
+
selector = Molecules::RuntimeSelector.new(config: Ace::Runtime.config)
|
|
42
|
+
runtime = selector.resolve(explicit: options[:runtime])
|
|
43
|
+
result = runtime.send(pane: options[:pane], command: command, items: items)
|
|
44
|
+
|
|
45
|
+
report(selector.selected_name, command, items, result) unless quiet?(options)
|
|
46
|
+
rescue Ace::Runtime::Error => e
|
|
47
|
+
raise Ace::Support::Cli::Error, e.message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# The callback CLI builds direct-API items as all supplied
|
|
53
|
+
# messages followed by all supplied keys; direct Ruby callers
|
|
54
|
+
# use send(items:) when they need arbitrary interleaving.
|
|
55
|
+
def build_request(options)
|
|
56
|
+
reject_leading_keys!
|
|
57
|
+
|
|
58
|
+
command = normalize_cmd(options[:cmd])
|
|
59
|
+
items = Array(options[:msg]).map { |text| {message: text} } +
|
|
60
|
+
Array(options[:key]).map { |name| {key: name} }
|
|
61
|
+
|
|
62
|
+
if command.nil? && items.empty?
|
|
63
|
+
raise Ace::Support::Cli::Error, "provide at least one of --cmd, --msg, or --key"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[command, items]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Leading keys (--key Esc --cmd run) are a normative usage
|
|
70
|
+
# error: --cmd must be declared before every key.
|
|
71
|
+
def reject_leading_keys!
|
|
72
|
+
cmd_index = first_flag_index(%w[--cmd -c])
|
|
73
|
+
key_indices = flag_indices(%w[--key -k])
|
|
74
|
+
return if cmd_index.nil? || key_indices.empty? || key_indices.min > cmd_index
|
|
75
|
+
|
|
76
|
+
raise Ace::Support::Cli::Error,
|
|
77
|
+
"--key before --cmd is a usage error: --cmd must be declared before every key"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def flag_indices(flags)
|
|
81
|
+
Ace::Runtime::CLI.last_invocation_args.each_index.select do |index|
|
|
82
|
+
token = Ace::Runtime::CLI.last_invocation_args[index]
|
|
83
|
+
flags.any? { |flag| token == flag || token.start_with?("#{flag}=") }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def first_flag_index(flags)
|
|
88
|
+
list = flag_indices(flags)
|
|
89
|
+
list.empty? ? nil : list.min
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_cmd(value)
|
|
93
|
+
text = value.to_s
|
|
94
|
+
return nil if text.strip.empty?
|
|
95
|
+
|
|
96
|
+
text
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def report(runtime_name, command, items, result)
|
|
100
|
+
parts = []
|
|
101
|
+
parts << "command" if command
|
|
102
|
+
message_count = items.count { |item| item.key?(:message) }
|
|
103
|
+
key_count = items.count { |item| item.key?(:key) }
|
|
104
|
+
parts << pluralize(message_count, "message") if message_count.positive?
|
|
105
|
+
parts << pluralize(key_count, "key") if key_count.positive?
|
|
106
|
+
|
|
107
|
+
line = "Sent via #{runtime_name}: #{parts.join(' + ')}"
|
|
108
|
+
line += " (dropped trailing Enter for agent pane)" if result&.dropped_trailing_enter
|
|
109
|
+
|
|
110
|
+
puts line
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pluralize(count, noun)
|
|
114
|
+
"#{count} #{noun}#{count == 1 ? '' : 's'}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "ace/core"
|
|
5
|
+
require_relative "../runtime"
|
|
6
|
+
require_relative "cli/commands/send"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
# ace-support-cli based CLI registry for ace-runtime. The contract
|
|
11
|
+
# ships exactly one agent-facing command: the neutral send
|
|
12
|
+
# passthrough (Fork Callback Rule decision, 2026-09-27).
|
|
13
|
+
module CLI
|
|
14
|
+
extend Ace::Support::Cli::RegistryDsl
|
|
15
|
+
|
|
16
|
+
PROGRAM_NAME = "ace-runtime"
|
|
17
|
+
|
|
18
|
+
# Application commands with descriptions (for help output)
|
|
19
|
+
REGISTERED_COMMANDS = [
|
|
20
|
+
["send", "Send a submitted command, text chunks, or named keys to a pane via the resolved terminal runtime"]
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
HELP_EXAMPLES = [
|
|
24
|
+
"ace-runtime send --pane %1 --cmd 'bundle exec rake test' # Submit a command",
|
|
25
|
+
"ace-runtime send --pane %1 --msg 'Reply with exactly: pong' --key Enter # Callback form: submits exactly once",
|
|
26
|
+
"ace-runtime send --runtime herdr --pane w1:p1 --msg 'hello' # Explicit runtime selection",
|
|
27
|
+
"ace-runtime send --pane %1 --key C-c # Keys-only send"
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Start the CLI
|
|
31
|
+
#
|
|
32
|
+
# @param args [Array<String>] Command-line arguments
|
|
33
|
+
# @return [Integer] Exit code (0 for success, non-zero for failure)
|
|
34
|
+
def self.start(args)
|
|
35
|
+
@last_invocation_args = args
|
|
36
|
+
Ace::Support::Cli::Runner.new(self).call(args: args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raw arguments of the current invocation. The send command reads
|
|
40
|
+
# these for flag declaration order (--cmd before every --key);
|
|
41
|
+
# falls back to ARGV when the CLI was not started through #start.
|
|
42
|
+
def self.last_invocation_args
|
|
43
|
+
@last_invocation_args || ARGV
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Register commands
|
|
47
|
+
register "send", CLI::Commands::Send.new
|
|
48
|
+
|
|
49
|
+
# Register version command
|
|
50
|
+
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
51
|
+
gem_name: "ace-runtime",
|
|
52
|
+
version: Ace::Runtime::VERSION
|
|
53
|
+
)
|
|
54
|
+
register "version", version_cmd
|
|
55
|
+
register "--version", version_cmd
|
|
56
|
+
|
|
57
|
+
# Register help command
|
|
58
|
+
help_cmd = Ace::Support::Cli::HelpCommand.build(
|
|
59
|
+
program_name: PROGRAM_NAME,
|
|
60
|
+
version: Ace::Runtime::VERSION,
|
|
61
|
+
commands: REGISTERED_COMMANDS,
|
|
62
|
+
examples: HELP_EXAMPLES
|
|
63
|
+
)
|
|
64
|
+
register "help", help_cmd
|
|
65
|
+
register "--help", help_cmd
|
|
66
|
+
register "-h", help_cmd
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
# Contract error hierarchy. Every runtime failure surfaces as one of
|
|
6
|
+
# these typed errors; adapters translate their native failures into
|
|
7
|
+
# them at the boundary.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# The requested runtime name has no registered adapter (after the
|
|
11
|
+
# convention-based entrypoint load attempt). Fails closed and names
|
|
12
|
+
# the available runtimes.
|
|
13
|
+
class UnknownRuntimeError < Error
|
|
14
|
+
attr_reader :requested, :available
|
|
15
|
+
|
|
16
|
+
def initialize(requested:, available:)
|
|
17
|
+
@requested = requested
|
|
18
|
+
@available = Array(available).sort.freeze
|
|
19
|
+
super("unknown runtime '#{requested}' (available: #{@available.join(', ')})")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The runtime is known but not usable right now (server unreachable,
|
|
24
|
+
# no live session/workspace). Never silently downgraded to another
|
|
25
|
+
# runtime or to a headless mode.
|
|
26
|
+
class RuntimeUnavailableError < Error; end
|
|
27
|
+
|
|
28
|
+
# A window/pane target does not exist. Target values are opaque,
|
|
29
|
+
# adapter-owned handles; the contract never predicts or formats them.
|
|
30
|
+
class TargetNotFoundError < Error; end
|
|
31
|
+
|
|
32
|
+
# ensure_window found an existing window with the same normalized
|
|
33
|
+
# name scoped to the current session/workspace but an incompatible
|
|
34
|
+
# root or preset.
|
|
35
|
+
class WindowConflictError < Error; end
|
|
36
|
+
|
|
37
|
+
# Pre-send rejection (invalid send shape or an agent pane blocking
|
|
38
|
+
# the delivery). Raised before any transport call; terminal.
|
|
39
|
+
class SendRejectedError < Error; end
|
|
40
|
+
|
|
41
|
+
# The submission was accepted but the target did not start
|
|
42
|
+
# processing it. The outcome is uncertain: callers must not
|
|
43
|
+
# auto-resend the same content.
|
|
44
|
+
class SendStalledError < Error; end
|
|
45
|
+
|
|
46
|
+
# A wait exceeded its deadline. Timeout units at the contract level
|
|
47
|
+
# are SECONDS; adapters convert to native units internally.
|
|
48
|
+
class WaitTimeoutError < Error
|
|
49
|
+
attr_reader :condition, :target, :timeout
|
|
50
|
+
|
|
51
|
+
def initialize(condition:, timeout:, target: nil)
|
|
52
|
+
@condition = condition
|
|
53
|
+
@target = target
|
|
54
|
+
@timeout = timeout
|
|
55
|
+
target_part = target ? " on '#{target}'" : ""
|
|
56
|
+
super("timed out waiting for #{condition}#{target_part} after #{timeout}s")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
module Molecules
|
|
6
|
+
# Runtime selection precedence: explicit name > ACE_RUNTIME >
|
|
7
|
+
# configured runtime > side-effect-free environment detection.
|
|
8
|
+
# Resolution happens only after selection. An explicitly selected
|
|
9
|
+
# runtime that turns out unknown or unavailable fails closed via
|
|
10
|
+
# the registry — never headless, never a silent switch to another
|
|
11
|
+
# runtime. Only the assign auto launch mode selects headless
|
|
12
|
+
# outside any runtime; that decision lives above this selector.
|
|
13
|
+
class RuntimeSelector
|
|
14
|
+
ENV_RUNTIME_KEY = "ACE_RUNTIME"
|
|
15
|
+
|
|
16
|
+
attr_reader :selected_name
|
|
17
|
+
|
|
18
|
+
def initialize(config: nil, registry: nil, env: ENV)
|
|
19
|
+
@config = config
|
|
20
|
+
@registry = registry
|
|
21
|
+
@env = env
|
|
22
|
+
@selected_name = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve(explicit: nil)
|
|
26
|
+
name = select_name(explicit: explicit)
|
|
27
|
+
|
|
28
|
+
if name.nil? || name.empty?
|
|
29
|
+
raise RuntimeUnavailableError,
|
|
30
|
+
"no terminal runtime selected: pass --runtime, set #{ENV_RUNTIME_KEY}, " \
|
|
31
|
+
"configure runtime under the ace-runtime config namespace, or run inside tmux/herdr"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@selected_name = name
|
|
35
|
+
registry.resolve(name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :config, :env
|
|
41
|
+
|
|
42
|
+
def registry
|
|
43
|
+
@registry || Ace::Runtime.registry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def select_name(explicit:)
|
|
47
|
+
name = explicit.to_s.strip
|
|
48
|
+
name = env_value(ENV_RUNTIME_KEY) if name.empty?
|
|
49
|
+
name = configured_runtime if name.empty?
|
|
50
|
+
name = detected_name if name.empty?
|
|
51
|
+
|
|
52
|
+
name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def env_value(key)
|
|
56
|
+
env[key].to_s.strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def configured_runtime
|
|
60
|
+
return "" unless config.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
value = config.key?("runtime") ? config["runtime"] : config[:runtime]
|
|
63
|
+
value.to_s.strip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def detected_name
|
|
67
|
+
detected = Atoms::Detector.detect(env: env)
|
|
68
|
+
detected ? detected.to_s : ""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
# Duck-typed adapter registry (ace-hitl Providers pattern). Runtime
|
|
6
|
+
# adapters register a factory under a simple name; resolution is
|
|
7
|
+
# fail-closed: an unknown name raises UnknownRuntimeError carrying
|
|
8
|
+
# the sorted available list. There is no base class — an adapter is
|
|
9
|
+
# whatever object implements the published intent operations.
|
|
10
|
+
class Registry
|
|
11
|
+
# Only identifier-shaped names may trigger the convention-based
|
|
12
|
+
# entrypoint load, so a hostile runtime name can never traverse
|
|
13
|
+
# the Ruby load path.
|
|
14
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
15
|
+
|
|
16
|
+
ADAPTER_ENTRYPOINT_FORMAT = "ace/runtime/adapters/%s"
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@factories = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register(name, factory = nil, &block)
|
|
23
|
+
key = name.to_s
|
|
24
|
+
callable = factory || block
|
|
25
|
+
raise ArgumentError, "adapter factory for '#{key}' must respond to #call" unless callable.respond_to?(:call)
|
|
26
|
+
|
|
27
|
+
@factories[key] = callable
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def registered?(name)
|
|
32
|
+
@factories.key?(name.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available
|
|
36
|
+
@factories.keys.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve(name)
|
|
40
|
+
key = name.to_s
|
|
41
|
+
load_adapter_entrypoint(key) unless @factories.key?(key)
|
|
42
|
+
|
|
43
|
+
factory = @factories[key]
|
|
44
|
+
raise UnknownRuntimeError.new(requested: key, available: available) unless factory
|
|
45
|
+
|
|
46
|
+
factory.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Convention: adapter packages ship a lib/ace/runtime/adapters/<name>.rb
|
|
52
|
+
# entrypoint that calls Ace::Runtime.register. It is attempted
|
|
53
|
+
# lazily on first resolve so the contract gem keeps no dependency
|
|
54
|
+
# on either adapter package. Only a missing entrypoint counts as
|
|
55
|
+
# "not installed"; a LoadError raised INSIDE an installed
|
|
56
|
+
# entrypoint (for example its own missing dependency) propagates
|
|
57
|
+
# so a broken adapter is never misreported as unknown.
|
|
58
|
+
def load_adapter_entrypoint(name)
|
|
59
|
+
return unless name.match?(NAME_PATTERN)
|
|
60
|
+
|
|
61
|
+
entrypoint = format(ADAPTER_ENTRYPOINT_FORMAT, name)
|
|
62
|
+
require entrypoint
|
|
63
|
+
rescue LoadError => e
|
|
64
|
+
raise unless e.path == entrypoint
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|