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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Testing
6
+ # Fixture-local signals. Adapters translate these at their
7
+ # boundary into the contract error model:
8
+ # FixtureUnavailable -> RuntimeUnavailableError
9
+ # FixtureStall -> SendStalledError
10
+ # FixtureTargetMissing -> TargetNotFoundError
11
+ class FixtureError < StandardError; end
12
+ class FixtureUnavailable < FixtureError; end
13
+ class FixtureStall < FixtureError; end
14
+ class FixtureTargetMissing < FixtureError; end
15
+
16
+ # In-memory terminal runtime used by the shared adapter contract
17
+ # suite. It models the native capabilities adapters bind to
18
+ # (windows, panes, raw text writes, named keys, capture, agent
19
+ # state) and records every mutation op in order so the suite can
20
+ # assert delivery ordering and rejection-before-transport.
21
+ #
22
+ # Real adapter suites bind their adapter to this same fixture via
23
+ # a thin transport bridge; the reference implementation is
24
+ # test/support/fake_runtime_adapter.rb in the ace-runtime gem.
25
+ class ScriptedRuntime
26
+ attr_reader :calls, :context, :windows, :panes
27
+ attr_accessor :unavailable
28
+
29
+ def initialize
30
+ @calls = []
31
+ @context = {in_runtime: true, session: "main", window: nil, pane: nil}
32
+ @windows = {}
33
+ @panes = {}
34
+ @unavailable = false
35
+ @stall_next_send = false
36
+ @window_seq = 0
37
+ @pane_seq = 0
38
+ end
39
+
40
+ # --- test controls (never recorded) ---
41
+
42
+ def set_context(context)
43
+ @context = context
44
+ end
45
+
46
+ def set_output(pane_id, text)
47
+ fetch_pane!(pane_id)[:output] = text
48
+ end
49
+
50
+ def set_agent_state(pane_id, state)
51
+ fetch_pane!(pane_id)[:agent_state] = state
52
+ end
53
+
54
+ def exit_pane(pane_id)
55
+ fetch_pane!(pane_id)[:alive] = false
56
+ end
57
+
58
+ def stall_next_send!
59
+ @stall_next_send = true
60
+ end
61
+
62
+ # Silent setup mutator for pre-existing windows; adapter-driven
63
+ # creation goes through #create_window so it gets recorded.
64
+ def seed_window(name, root: "/", preset: nil)
65
+ @windows[name] = {
66
+ id: next_window_id, root: root, preset: preset, focused: false, panes: []
67
+ }
68
+ @windows[name][:id]
69
+ end
70
+
71
+ # Silent setup mutator for pre-existing panes.
72
+ def seed_pane(window_name, alive: true, keep_alive: true)
73
+ pane_id = next_pane_id
74
+ @panes[pane_id] = {
75
+ window: window_name, alive: alive, keep_alive: keep_alive,
76
+ output: "", agent_state: nil
77
+ }
78
+ @windows.fetch(window_name)[:panes] << pane_id
79
+ pane_id
80
+ end
81
+
82
+ # --- queries (never recorded) ---
83
+
84
+ def unavailable?
85
+ @unavailable
86
+ end
87
+
88
+ def window_exists?(name)
89
+ @windows.key?(name)
90
+ end
91
+
92
+ def window_info(name)
93
+ @windows[name]
94
+ end
95
+
96
+ def pane_exists?(pane_id)
97
+ @panes.key?(pane_id)
98
+ end
99
+
100
+ def alive?(pane_id)
101
+ fetch_pane!(pane_id)[:alive]
102
+ end
103
+
104
+ def agent_state(pane_id)
105
+ fetch_pane!(pane_id)[:agent_state]
106
+ end
107
+
108
+ # One-shot: a scripted stall means the submission was accepted
109
+ # but the target never started processing it.
110
+ def take_stall!
111
+ stall = @stall_next_send
112
+ @stall_next_send = false
113
+ stall
114
+ end
115
+
116
+ # --- native mutation ops (recorded in order) ---
117
+
118
+ def create_window(name:, root:, preset: nil)
119
+ guard_available!
120
+ raise FixtureTargetMissing, "window '#{name}' already exists" if window_exists?(name)
121
+
122
+ record(:create_window, name: name, root: root, preset: preset)
123
+ @windows[name] = {
124
+ id: next_window_id, root: root, preset: preset, focused: false, panes: []
125
+ }
126
+ @windows[name][:id]
127
+ end
128
+
129
+ def kill_window(name)
130
+ guard_available!
131
+ raise FixtureTargetMissing, "no window '#{name}'" unless window_exists?(name)
132
+
133
+ record(:kill_window, name: name)
134
+ @windows.delete(name)
135
+ end
136
+
137
+ def focus_window(name)
138
+ guard_available!
139
+ raise FixtureTargetMissing, "no window '#{name}'" unless window_exists?(name)
140
+
141
+ record(:focus_window, name: name)
142
+ @windows.each_value { |info| info[:focused] = false }
143
+ @windows[name][:focused] = true
144
+ end
145
+
146
+ def split_window(window_name)
147
+ guard_available!
148
+ info = @windows[window_name]
149
+ raise FixtureTargetMissing, "no window '#{window_name}'" unless info
150
+
151
+ record(:split_window, window: window_name)
152
+ pane_id = next_pane_id
153
+ @panes[pane_id] = {
154
+ window: window_name, alive: true, keep_alive: false,
155
+ output: "", agent_state: nil
156
+ }
157
+ info[:panes] << pane_id
158
+ pane_id
159
+ end
160
+
161
+ def set_keep_alive(pane_id, value)
162
+ guard_available!
163
+ fetch_pane!(pane_id)
164
+
165
+ record(:set_keep_alive, pane: pane_id, value: value)
166
+ @panes[pane_id][:keep_alive] = value
167
+ end
168
+
169
+ def write_text(pane_id, text)
170
+ guard_available!
171
+ pane = fetch_pane!(pane_id)
172
+
173
+ raise FixtureStall, "target accepted the submission but never started processing" if take_stall!
174
+
175
+ record(:write_text, pane: pane_id, text: text)
176
+ pane[:output] = text
177
+ end
178
+
179
+ def press_key(pane_id, key)
180
+ guard_available!
181
+ pane = fetch_pane!(pane_id)
182
+
183
+ raise FixtureStall, "target accepted the submission but never started processing" if take_stall!
184
+
185
+ record(:press_key, pane: pane_id, key: key)
186
+ pane[:alive] = false if key.to_s.match?(/\Aexit\z/i)
187
+ end
188
+
189
+ def capture_output(pane_id, lines:)
190
+ guard_available!
191
+ output = fetch_pane!(pane_id)[:output]
192
+ output.lines.last([lines, 1].max).join
193
+ end
194
+
195
+ private
196
+
197
+ def record(op, **args)
198
+ @calls << {op: op}.merge(args)
199
+ end
200
+
201
+ def guard_available!
202
+ raise FixtureUnavailable, "runtime backend is not reachable" if @unavailable
203
+ end
204
+
205
+ def fetch_pane!(pane_id)
206
+ pane = @panes[pane_id]
207
+ raise FixtureTargetMissing, "no pane '#{pane_id}'" unless pane
208
+
209
+ pane
210
+ end
211
+
212
+ def next_window_id
213
+ @window_seq += 1
214
+ "w#{@window_seq}"
215
+ end
216
+
217
+ def next_pane_id
218
+ @pane_seq += 1
219
+ "%#{@pane_seq}"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Packaged adapter contract-test kit. Adapter packages require this in
4
+ # their own test helpers:
5
+ #
6
+ # require "ace/runtime/testing"
7
+ #
8
+ require_relative "testing/scripted_runtime"
9
+ require_relative "testing/adapter_contract"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/version"
4
+ require "ace/support/config"
5
+
6
+ # Define the error hierarchy before loading components (they raise these)
7
+ require_relative "runtime/errors"
8
+
9
+ require_relative "runtime/atoms/detector"
10
+ require_relative "runtime/atoms/name_sanitizer"
11
+ require_relative "runtime/atoms/send_contract"
12
+ require_relative "runtime/registry"
13
+ require_relative "runtime/molecules/runtime_selector"
14
+
15
+ module Ace
16
+ module Runtime
17
+ class << self
18
+ # Register a duck-typed adapter factory under a runtime name.
19
+ # Adapter packages call this from their
20
+ # lib/ace/runtime/adapters/<name>.rb entrypoint.
21
+ def register(name, factory = nil, &block)
22
+ registry.register(name, factory, &block)
23
+ end
24
+
25
+ def registry
26
+ @registry ||= Registry.new
27
+ end
28
+
29
+ def reset_registry!
30
+ @registry = Registry.new
31
+ end
32
+
33
+ def available
34
+ registry.available
35
+ end
36
+
37
+ def registered?(name)
38
+ registry.registered?(name)
39
+ end
40
+
41
+ # Resolve a runtime name to a registered adapter. Unknown names
42
+ # fail closed with UnknownRuntimeError listing the available
43
+ # runtimes.
44
+ def resolve(name)
45
+ registry.resolve(name)
46
+ end
47
+
48
+ # Side-effect-free detection: :tmux, :herdr, or nil. tmux wins
49
+ # when both environments are live.
50
+ def detect(env: ENV)
51
+ Atoms::Detector.detect(env: env)
52
+ end
53
+
54
+ # Shared window/tab naming policy.
55
+ def sanitize_name(name, fallback: Atoms::NameSanitizer::FALLBACK)
56
+ Atoms::NameSanitizer.call(name, fallback: fallback)
57
+ end
58
+
59
+ def gem_root
60
+ @gem_root ||= ::Gem.loaded_specs["ace-runtime"]&.gem_dir ||
61
+ File.expand_path("../..", __dir__)
62
+ end
63
+
64
+ def debug?
65
+ ENV["ACE_DEBUG"] == "1" || ENV["DEBUG"] == "1"
66
+ end
67
+
68
+ # Configured runtime selection (key: `runtime`), merged through the
69
+ # ACE config cascade (project .ace/ > user ~/.ace/ > gem defaults).
70
+ def config
71
+ @config_mutex.synchronize do
72
+ @config ||= load_config
73
+ end
74
+ end
75
+
76
+ def reset_config!
77
+ @config_mutex.synchronize do
78
+ @config = nil
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def load_config
85
+ resolver = Ace::Support::Config.create(
86
+ config_dir: ".ace",
87
+ defaults_dir: ".ace-defaults",
88
+ gem_path: gem_root
89
+ )
90
+
91
+ resolver.resolve_namespace("runtime").data
92
+ rescue StandardError => e
93
+ warn "ace-runtime: Could not load config: #{e.class} - #{e.message}" if debug?
94
+ {}
95
+ end
96
+ end
97
+
98
+ @config_mutex = Mutex.new
99
+ end
100
+ end
101
+
102
+ require_relative "runtime/cli"
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-runtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-support-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ace-support-config
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.18'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.18'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ace-support-core
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.31'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.31'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ace-support-test-helpers
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.14'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.14'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bundler
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: minitest
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '5.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '5.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.88'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.88'
124
+ - !ruby/object:Gem::Dependency
125
+ name: simplecov
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.22'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '0.22'
138
+ description: 'One duck-typed intent API for terminal runtimes: adapter registry with
139
+ fail-closed resolution, side-effect-free detection, shared name sanitization, typed
140
+ errors, central send-matrix validation, the ace-runtime send callback CLI, and the
141
+ shared adapter contract-test suite.'
142
+ email:
143
+ - mc@cs3b.com
144
+ executables:
145
+ - ace-runtime
146
+ extensions: []
147
+ extra_rdoc_files: []
148
+ files:
149
+ - ".ace-defaults/runtime/config.yml"
150
+ - CHANGELOG.md
151
+ - LICENSE
152
+ - README.md
153
+ - Rakefile
154
+ - docs/usage.md
155
+ - exe/ace-runtime
156
+ - lib/ace/runtime.rb
157
+ - lib/ace/runtime/atoms/detector.rb
158
+ - lib/ace/runtime/atoms/name_sanitizer.rb
159
+ - lib/ace/runtime/atoms/send_contract.rb
160
+ - lib/ace/runtime/cli.rb
161
+ - lib/ace/runtime/cli/commands/send.rb
162
+ - lib/ace/runtime/errors.rb
163
+ - lib/ace/runtime/molecules/runtime_selector.rb
164
+ - lib/ace/runtime/registry.rb
165
+ - lib/ace/runtime/testing.rb
166
+ - lib/ace/runtime/testing/adapter_contract.rb
167
+ - lib/ace/runtime/testing/scripted_runtime.rb
168
+ - lib/ace/runtime/version.rb
169
+ homepage: https://github.com/cs3b/ace
170
+ licenses:
171
+ - MIT
172
+ metadata:
173
+ allowed_push_host: https://rubygems.org
174
+ homepage_uri: https://github.com/cs3b/ace
175
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-runtime/
176
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-runtime/CHANGELOG.md
177
+ rdoc_options: []
178
+ require_paths:
179
+ - lib
180
+ required_ruby_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: 3.2.0
185
+ required_rubygems_version: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - ">="
188
+ - !ruby/object:Gem::Version
189
+ version: '0'
190
+ requirements: []
191
+ rubygems_version: 3.6.9
192
+ specification_version: 4
193
+ summary: Runtime-neutral terminal intent contract for ACE
194
+ test_files: []