harnex 0.2.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/GUIDE.md +242 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/TECHNICAL.md +595 -0
- data/bin/harnex +18 -0
- data/lib/harnex/adapters/base.rb +134 -0
- data/lib/harnex/adapters/claude.rb +105 -0
- data/lib/harnex/adapters/codex.rb +112 -0
- data/lib/harnex/adapters/generic.rb +14 -0
- data/lib/harnex/adapters.rb +32 -0
- data/lib/harnex/cli.rb +115 -0
- data/lib/harnex/commands/guide.rb +23 -0
- data/lib/harnex/commands/logs.rb +184 -0
- data/lib/harnex/commands/pane.rb +251 -0
- data/lib/harnex/commands/recipes.rb +104 -0
- data/lib/harnex/commands/run.rb +384 -0
- data/lib/harnex/commands/send.rb +415 -0
- data/lib/harnex/commands/skills.rb +163 -0
- data/lib/harnex/commands/status.rb +171 -0
- data/lib/harnex/commands/stop.rb +127 -0
- data/lib/harnex/commands/wait.rb +165 -0
- data/lib/harnex/core.rb +286 -0
- data/lib/harnex/runtime/api_server.rb +187 -0
- data/lib/harnex/runtime/file_change_hook.rb +111 -0
- data/lib/harnex/runtime/inbox.rb +207 -0
- data/lib/harnex/runtime/message.rb +23 -0
- data/lib/harnex/runtime/session.rb +380 -0
- data/lib/harnex/runtime/session_state.rb +55 -0
- data/lib/harnex/version.rb +3 -0
- data/lib/harnex/watcher/inotify.rb +43 -0
- data/lib/harnex/watcher/polling.rb +92 -0
- data/lib/harnex/watcher.rb +24 -0
- data/lib/harnex.rb +25 -0
- data/recipes/01_fire_and_watch.md +82 -0
- data/recipes/02_chain_implement.md +115 -0
- data/skills/chain-implement/SKILL.md +234 -0
- data/skills/close/SKILL.md +47 -0
- data/skills/dispatch/SKILL.md +171 -0
- data/skills/harnex/SKILL.md +304 -0
- data/skills/open/SKILL.md +32 -0
- metadata +88 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
class Inbox
|
|
5
|
+
DEFAULT_TTL = 120
|
|
6
|
+
MAX_PENDING = 64
|
|
7
|
+
DELIVERY_TIMEOUT = 300
|
|
8
|
+
|
|
9
|
+
def initialize(session, state_machine, ttl: DEFAULT_TTL)
|
|
10
|
+
@session = session
|
|
11
|
+
@state_machine = state_machine
|
|
12
|
+
@ttl = ttl.to_f
|
|
13
|
+
@queue = []
|
|
14
|
+
@messages = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@condvar = ConditionVariable.new
|
|
17
|
+
@thread = nil
|
|
18
|
+
@running = false
|
|
19
|
+
@delivered_total = 0
|
|
20
|
+
@expired_total = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
@running = true
|
|
25
|
+
@thread = Thread.new { delivery_loop }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@running = false
|
|
30
|
+
@mutex.synchronize { @condvar.broadcast }
|
|
31
|
+
@thread&.join(2)
|
|
32
|
+
@thread&.kill
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def enqueue(text:, submit:, enter_only:, force: false)
|
|
36
|
+
msg = Message.new(
|
|
37
|
+
id: SecureRandom.hex(8),
|
|
38
|
+
text: text,
|
|
39
|
+
submit: submit,
|
|
40
|
+
enter_only: enter_only,
|
|
41
|
+
force: force,
|
|
42
|
+
queued_at: Time.now,
|
|
43
|
+
status: :queued
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Force messages bypass the queue entirely
|
|
47
|
+
if force
|
|
48
|
+
return deliver_now(msg)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fast path: prompt ready and queue empty — deliver immediately.
|
|
52
|
+
# Check under lock, then release before calling deliver_now to
|
|
53
|
+
# avoid recursive locking (deliver_now also acquires @mutex).
|
|
54
|
+
try_fast = @mutex.synchronize do
|
|
55
|
+
@queue.empty? && @state_machine.state == :prompt
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if try_fast
|
|
59
|
+
begin
|
|
60
|
+
result = deliver_now(msg)
|
|
61
|
+
return result if msg.status == :delivered
|
|
62
|
+
rescue StandardError
|
|
63
|
+
# Fall through to queue if delivery failed
|
|
64
|
+
end
|
|
65
|
+
msg.status = :queued
|
|
66
|
+
msg.error = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
raise "inbox full (#{MAX_PENDING} pending messages)" if @queue.size >= MAX_PENDING
|
|
71
|
+
|
|
72
|
+
@queue << msg
|
|
73
|
+
@messages[msg.id] = msg
|
|
74
|
+
@condvar.broadcast
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ ok: true, status: "queued", message_id: msg.id, http_status: 202 }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def message_status(id)
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
msg = @messages[id]
|
|
83
|
+
return nil unless msg
|
|
84
|
+
msg.to_h
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stats
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
{ pending: @queue.size, delivered_total: @delivered_total, expired_total: @expired_total }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pending_messages
|
|
95
|
+
@mutex.synchronize { @queue.map(&:to_h) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def drop(message_id)
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
msg = @messages[message_id]
|
|
101
|
+
return nil unless msg && @queue.any? { |queued| queued.id == message_id }
|
|
102
|
+
|
|
103
|
+
@queue.delete_if { |queued| queued.id == message_id }
|
|
104
|
+
msg.status = :dropped
|
|
105
|
+
msg.to_h
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def clear
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
count = @queue.size
|
|
112
|
+
@queue.each { |msg| msg.status = :dropped }
|
|
113
|
+
@queue.clear
|
|
114
|
+
count
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def deliver_now(msg)
|
|
121
|
+
result = @session.inject_via_adapter(
|
|
122
|
+
text: msg.text,
|
|
123
|
+
submit: msg.submit,
|
|
124
|
+
enter_only: msg.enter_only,
|
|
125
|
+
force: msg.force
|
|
126
|
+
)
|
|
127
|
+
msg.status = :delivered
|
|
128
|
+
msg.delivered_at = Time.now
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@delivered_total += 1
|
|
131
|
+
@messages[msg.id] = msg
|
|
132
|
+
end
|
|
133
|
+
result.merge(ok: true, status: "delivered", message_id: msg.id, http_status: 200)
|
|
134
|
+
rescue ArgumentError => e
|
|
135
|
+
msg.status = :failed
|
|
136
|
+
msg.error = e.message
|
|
137
|
+
@mutex.synchronize { @messages[msg.id] = msg }
|
|
138
|
+
raise
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def delivery_loop
|
|
142
|
+
while @running
|
|
143
|
+
msg = @mutex.synchronize do
|
|
144
|
+
expire_stale_messages_locked
|
|
145
|
+
while @queue.empty? && @running
|
|
146
|
+
@condvar.wait(@mutex, 1.0)
|
|
147
|
+
expire_stale_messages_locked
|
|
148
|
+
end
|
|
149
|
+
@queue.first
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
break unless @running
|
|
153
|
+
next unless msg
|
|
154
|
+
|
|
155
|
+
ready = @state_machine.wait_for_prompt(prompt_wait_timeout)
|
|
156
|
+
unless ready
|
|
157
|
+
next if @running # Keep waiting
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
msg = @mutex.synchronize do
|
|
161
|
+
expire_stale_messages_locked
|
|
162
|
+
@queue.first
|
|
163
|
+
end
|
|
164
|
+
next unless msg
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
deliver_now(msg)
|
|
168
|
+
@mutex.synchronize { @queue.shift if @queue.first&.id == msg.id }
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
# State race — will retry on next loop iteration
|
|
171
|
+
sleep 0.1
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
msg.status = :failed
|
|
174
|
+
msg.error = e.message
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
@queue.shift
|
|
177
|
+
@messages[msg.id] = msg
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def expire_stale_messages
|
|
184
|
+
@mutex.synchronize { expire_stale_messages_locked }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def expire_stale_messages_locked(now = Time.now)
|
|
188
|
+
while (msg = @queue.first) && stale_message?(msg, now)
|
|
189
|
+
msg.status = :expired
|
|
190
|
+
@queue.shift
|
|
191
|
+
@expired_total += 1
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def stale_message?(msg, now)
|
|
196
|
+
return false unless msg.queued_at
|
|
197
|
+
|
|
198
|
+
(now - msg.queued_at) > @ttl
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def prompt_wait_timeout
|
|
202
|
+
return 0.1 if @ttl <= 0.0
|
|
203
|
+
|
|
204
|
+
[DELIVERY_TIMEOUT.to_f, @ttl].min
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error, keyword_init: true) do
|
|
3
|
+
def to_h
|
|
4
|
+
{
|
|
5
|
+
id: id,
|
|
6
|
+
status: status.to_s,
|
|
7
|
+
queued_at: queued_at&.iso8601,
|
|
8
|
+
delivered_at: delivered_at&.iso8601,
|
|
9
|
+
text_preview: preview_text,
|
|
10
|
+
error: error
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def preview_text(limit = 80)
|
|
17
|
+
compact = text.to_s.gsub(/\s+/, " ").strip
|
|
18
|
+
return compact if compact.length <= limit
|
|
19
|
+
|
|
20
|
+
"#{compact[0, limit - 3]}..."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pty"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
class Session
|
|
7
|
+
OUTPUT_BUFFER_LIMIT = 64 * 1024
|
|
8
|
+
|
|
9
|
+
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
|
|
10
|
+
|
|
11
|
+
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
|
|
12
|
+
@adapter = adapter
|
|
13
|
+
@command = command
|
|
14
|
+
@repo_root = repo_root
|
|
15
|
+
@host = host
|
|
16
|
+
@id = Harnex.normalize_id(id)
|
|
17
|
+
@watch = watch
|
|
18
|
+
@description = description.to_s.strip
|
|
19
|
+
@description = nil if @description.empty?
|
|
20
|
+
@registry_path = Harnex.registry_path(repo_root, @id)
|
|
21
|
+
@output_log_path = Harnex.output_log_path(repo_root, @id)
|
|
22
|
+
@session_id = SecureRandom.hex(8)
|
|
23
|
+
@token = SecureRandom.hex(16)
|
|
24
|
+
@port = Harnex.allocate_port(repo_root, @id, port, host: host)
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@inject_mutex = Mutex.new
|
|
27
|
+
@injected_count = 0
|
|
28
|
+
@last_injected_at = nil
|
|
29
|
+
@started_at = Time.now
|
|
30
|
+
@server = nil
|
|
31
|
+
@reader = nil
|
|
32
|
+
@output_log = nil
|
|
33
|
+
@writer = nil
|
|
34
|
+
@pid = nil
|
|
35
|
+
@term_signal = nil
|
|
36
|
+
@output_buffer = +""
|
|
37
|
+
@output_buffer.force_encoding(Encoding::BINARY)
|
|
38
|
+
@state_machine = SessionState.new(adapter)
|
|
39
|
+
@inbox = Inbox.new(self, @state_machine, ttl: inbox_ttl)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.validate_binary!(command)
|
|
43
|
+
binary = Array(command).first.to_s
|
|
44
|
+
raise BinaryNotFound, "\"\" not found — is it installed and on your PATH?" if binary.empty?
|
|
45
|
+
|
|
46
|
+
if binary.include?("/")
|
|
47
|
+
return binary if File.executable?(binary) && !File.directory?(binary)
|
|
48
|
+
|
|
49
|
+
raise BinaryNotFound, "\"#{binary}\" not found — is it installed and on your PATH?"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
53
|
+
path = File.join(dir, binary)
|
|
54
|
+
return path if File.executable?(path) && !File.directory?(path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
raise BinaryNotFound, "\"#{binary}\" not found — is it installed and on your PATH?"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run(validate_binary: true)
|
|
61
|
+
validate_binary! if validate_binary
|
|
62
|
+
prepare_output_log
|
|
63
|
+
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
64
|
+
@writer.sync = true
|
|
65
|
+
|
|
66
|
+
install_signal_handlers
|
|
67
|
+
sync_window_size
|
|
68
|
+
@server = ApiServer.new(self)
|
|
69
|
+
@server.start
|
|
70
|
+
persist_registry
|
|
71
|
+
|
|
72
|
+
stdin_state = STDIN.tty? ? STDIN.raw! : nil
|
|
73
|
+
watch_thread = start_watch_thread
|
|
74
|
+
@inbox.start
|
|
75
|
+
input_thread = start_input_thread
|
|
76
|
+
output_thread = start_output_thread
|
|
77
|
+
|
|
78
|
+
_, status = Process.wait2(pid)
|
|
79
|
+
@term_signal = status.signaled? ? status.termsig : nil
|
|
80
|
+
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
81
|
+
|
|
82
|
+
output_thread.join(1)
|
|
83
|
+
input_thread&.kill
|
|
84
|
+
watch_thread&.kill
|
|
85
|
+
@exit_code
|
|
86
|
+
ensure
|
|
87
|
+
@inbox.stop
|
|
88
|
+
STDIN.cooked! if STDIN.tty? && stdin_state
|
|
89
|
+
@server&.stop
|
|
90
|
+
persist_exit_status
|
|
91
|
+
cleanup_registry
|
|
92
|
+
@reader&.close unless @reader&.closed?
|
|
93
|
+
@output_log&.close unless @output_log&.closed?
|
|
94
|
+
@writer&.close unless @writer&.closed?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def status_payload(include_input_state: true)
|
|
98
|
+
payload = {
|
|
99
|
+
ok: true,
|
|
100
|
+
session_id: session_id,
|
|
101
|
+
repo_root: repo_root,
|
|
102
|
+
repo_key: Harnex.repo_key(repo_root),
|
|
103
|
+
cli: adapter.key,
|
|
104
|
+
id: id,
|
|
105
|
+
pid: pid,
|
|
106
|
+
host: host,
|
|
107
|
+
port: port,
|
|
108
|
+
command: command,
|
|
109
|
+
started_at: @started_at.iso8601,
|
|
110
|
+
last_injected_at: @last_injected_at&.iso8601,
|
|
111
|
+
injected_count: @injected_count,
|
|
112
|
+
output_log_path: output_log_path
|
|
113
|
+
}
|
|
114
|
+
payload[:description] = description if description
|
|
115
|
+
|
|
116
|
+
if watch
|
|
117
|
+
payload[:watch_path] = watch.display_path
|
|
118
|
+
payload[:watch_absolute_path] = watch.absolute_path
|
|
119
|
+
payload[:watch_debounce_seconds] = watch.debounce_seconds
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
|
|
123
|
+
payload[:agent_state] = @state_machine.to_s
|
|
124
|
+
payload[:inbox] = @inbox.stats
|
|
125
|
+
payload
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def auth_ok?(header)
|
|
129
|
+
header == "Bearer #{token}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def inject(text, newline: true)
|
|
133
|
+
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
134
|
+
|
|
135
|
+
inject_sequence([{ text: text, newline: newline }])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def inject_stop
|
|
139
|
+
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
140
|
+
|
|
141
|
+
@inject_mutex.synchronize do
|
|
142
|
+
adapter.inject_exit(@writer)
|
|
143
|
+
@state_machine.force_busy!
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{ ok: true, signal: "exit_sequence_sent" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def inject_via_adapter(text:, submit:, enter_only:, force: false)
|
|
150
|
+
snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
|
|
151
|
+
payload = adapter.build_send_payload(
|
|
152
|
+
text: text,
|
|
153
|
+
submit: submit,
|
|
154
|
+
enter_only: enter_only,
|
|
155
|
+
screen_text: snapshot,
|
|
156
|
+
force: force
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
result =
|
|
160
|
+
if payload[:steps]
|
|
161
|
+
inject_sequence(payload.fetch(:steps))
|
|
162
|
+
else
|
|
163
|
+
inject(payload.fetch(:text), newline: payload.fetch(:newline, false))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
result.merge(
|
|
167
|
+
cli: adapter.key,
|
|
168
|
+
input_state: payload[:input_state],
|
|
169
|
+
force: payload[:force]
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def sync_window_size
|
|
174
|
+
return unless STDIN.tty?
|
|
175
|
+
|
|
176
|
+
@writer.winsize = STDIN.winsize
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_binary!
|
|
182
|
+
self.class.validate_binary!(command)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def child_env
|
|
188
|
+
env = {
|
|
189
|
+
"HARNEX_SESSION_ID" => session_id,
|
|
190
|
+
"HARNEX_SESSION_CLI" => adapter.key,
|
|
191
|
+
"HARNEX_ID" => id,
|
|
192
|
+
"HARNEX_SESSION_REPO_ROOT" => repo_root
|
|
193
|
+
}
|
|
194
|
+
env["HARNEX_DESCRIPTION"] = description if description
|
|
195
|
+
env
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def inject_sequence(steps)
|
|
199
|
+
@inject_mutex.synchronize do
|
|
200
|
+
total_bytes = 0
|
|
201
|
+
newline = false
|
|
202
|
+
|
|
203
|
+
steps.each do |step|
|
|
204
|
+
delay_ms = step[:delay_ms].to_i
|
|
205
|
+
sleep(delay_ms / 1000.0) if delay_ms.positive?
|
|
206
|
+
|
|
207
|
+
payload = step.fetch(:text, "").dup
|
|
208
|
+
newline = step.fetch(:newline, false)
|
|
209
|
+
payload << "\n" if newline
|
|
210
|
+
total_bytes += write_payload(payload)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
result = finish_injection(bytes_written: total_bytes, newline: newline)
|
|
214
|
+
@state_machine.force_busy!
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def write_payload(payload)
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
bytes = @writer.write(payload)
|
|
222
|
+
@writer.flush
|
|
223
|
+
bytes
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def finish_injection(bytes_written:, newline:)
|
|
228
|
+
injected_count = @mutex.synchronize do
|
|
229
|
+
@injected_count += 1
|
|
230
|
+
@last_injected_at = Time.now
|
|
231
|
+
persist_registry
|
|
232
|
+
@injected_count
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
{
|
|
236
|
+
ok: true,
|
|
237
|
+
bytes_written: bytes_written,
|
|
238
|
+
injected_count: injected_count,
|
|
239
|
+
newline: newline
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def registry_payload
|
|
244
|
+
status_payload(include_input_state: false).merge(
|
|
245
|
+
token: token,
|
|
246
|
+
cwd: Dir.pwd
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def persist_registry
|
|
251
|
+
payload = registry_payload
|
|
252
|
+
preserved = load_existing_registry_metadata
|
|
253
|
+
payload = payload.merge(preserved) unless preserved.empty?
|
|
254
|
+
Harnex.write_registry(@registry_path, payload)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def persist_exit_status
|
|
258
|
+
return unless defined?(@exit_code) && !@exit_code.nil?
|
|
259
|
+
|
|
260
|
+
exit_path = Harnex.exit_status_path(repo_root, id)
|
|
261
|
+
payload = {
|
|
262
|
+
ok: true,
|
|
263
|
+
id: id,
|
|
264
|
+
cli: adapter.key,
|
|
265
|
+
session_id: session_id,
|
|
266
|
+
repo_root: repo_root,
|
|
267
|
+
exit_code: @exit_code,
|
|
268
|
+
started_at: @started_at.iso8601,
|
|
269
|
+
exited_at: Time.now.iso8601,
|
|
270
|
+
injected_count: @injected_count
|
|
271
|
+
}
|
|
272
|
+
payload[:signal] = @term_signal if @term_signal
|
|
273
|
+
Harnex.write_registry(exit_path, payload)
|
|
274
|
+
rescue StandardError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def cleanup_registry
|
|
279
|
+
current = File.exist?(@registry_path) ? JSON.parse(File.read(@registry_path)) : nil
|
|
280
|
+
return unless current && current["session_id"] == session_id
|
|
281
|
+
|
|
282
|
+
FileUtils.rm_f(@registry_path)
|
|
283
|
+
rescue JSON::ParserError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def start_input_thread
|
|
288
|
+
Thread.new do
|
|
289
|
+
loop do
|
|
290
|
+
chunk = STDIN.readpartial(4096)
|
|
291
|
+
@inject_mutex.synchronize do
|
|
292
|
+
@mutex.synchronize do
|
|
293
|
+
@writer.write(chunk)
|
|
294
|
+
@writer.flush
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
rescue EOFError, Errno::EIO, IOError
|
|
298
|
+
break
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def start_output_thread
|
|
304
|
+
Thread.new do
|
|
305
|
+
loop do
|
|
306
|
+
chunk = @reader.readpartial(4096)
|
|
307
|
+
record_output(chunk)
|
|
308
|
+
STDOUT.write(chunk)
|
|
309
|
+
STDOUT.flush
|
|
310
|
+
rescue EOFError, Errno::EIO, IOError
|
|
311
|
+
break
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def start_watch_thread
|
|
317
|
+
return nil unless watch
|
|
318
|
+
|
|
319
|
+
FileChangeHook.new(self, watch).start
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def prepare_output_log
|
|
323
|
+
@output_log&.close unless @output_log&.closed?
|
|
324
|
+
@output_log = File.open(output_log_path, "ab")
|
|
325
|
+
@output_log.sync = true
|
|
326
|
+
@output_log_failed = false
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def install_signal_handlers
|
|
330
|
+
%w[INT TERM HUP QUIT].each do |signal_name|
|
|
331
|
+
Signal.trap(signal_name) { forward_signal(signal_name) }
|
|
332
|
+
end
|
|
333
|
+
Signal.trap("WINCH") { sync_window_size }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def forward_signal(signal_name)
|
|
337
|
+
return unless pid
|
|
338
|
+
|
|
339
|
+
Process.kill(signal_name, pid)
|
|
340
|
+
rescue Errno::ESRCH
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def record_output(chunk)
|
|
345
|
+
snapshot = @mutex.synchronize do
|
|
346
|
+
append_output_log(chunk)
|
|
347
|
+
@output_buffer << chunk
|
|
348
|
+
overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
|
|
349
|
+
@output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
|
|
350
|
+
@output_buffer.dup
|
|
351
|
+
end
|
|
352
|
+
@state_machine.update(snapshot)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def append_output_log(chunk)
|
|
356
|
+
return unless @output_log
|
|
357
|
+
|
|
358
|
+
@output_log.write(chunk)
|
|
359
|
+
rescue StandardError => e
|
|
360
|
+
return if defined?(@output_log_failed) && @output_log_failed
|
|
361
|
+
|
|
362
|
+
@output_log_failed = true
|
|
363
|
+
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def screen_snapshot
|
|
367
|
+
@mutex.synchronize { @output_buffer.dup }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def load_existing_registry_metadata
|
|
371
|
+
return {} unless File.exist?(@registry_path)
|
|
372
|
+
|
|
373
|
+
JSON.parse(File.read(@registry_path)).each_with_object({}) do |(key, value), memo|
|
|
374
|
+
memo[key] = value if key.start_with?("tmux_")
|
|
375
|
+
end
|
|
376
|
+
rescue JSON::ParserError
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
class SessionState
|
|
3
|
+
STATES = %i[prompt busy blocked unknown].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :state
|
|
6
|
+
|
|
7
|
+
def initialize(adapter)
|
|
8
|
+
@adapter = adapter
|
|
9
|
+
@state = :unknown
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@condvar = ConditionVariable.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update(screen_snapshot)
|
|
15
|
+
input = @adapter.input_state(screen_snapshot)
|
|
16
|
+
new_state =
|
|
17
|
+
case input[:input_ready]
|
|
18
|
+
when true then :prompt
|
|
19
|
+
when false then :blocked
|
|
20
|
+
else :unknown
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
old = @state
|
|
25
|
+
@state = new_state
|
|
26
|
+
@condvar.broadcast if old != new_state
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
new_state
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def force_busy!
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@state = :busy
|
|
35
|
+
@condvar.broadcast
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wait_for_prompt(timeout)
|
|
40
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
loop do
|
|
43
|
+
return true if @state == :prompt
|
|
44
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
return false if remaining <= 0
|
|
46
|
+
@condvar.wait(@mutex, remaining)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
@mutex.synchronize { @state.to_s }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "fiddle/import"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
module Inotify
|
|
5
|
+
extend Fiddle::Importer
|
|
6
|
+
|
|
7
|
+
IN_ATTRIB = 0x00000004
|
|
8
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
9
|
+
IN_CREATE = 0x00000100
|
|
10
|
+
IN_MOVED_TO = 0x00000080
|
|
11
|
+
|
|
12
|
+
@available = false
|
|
13
|
+
begin
|
|
14
|
+
dlload Fiddle.dlopen(nil)
|
|
15
|
+
extern "int inotify_init(void)"
|
|
16
|
+
extern "int inotify_add_watch(int, const char*, unsigned int)"
|
|
17
|
+
@available = true
|
|
18
|
+
rescue Fiddle::DLError
|
|
19
|
+
@available = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def available?
|
|
24
|
+
@available
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def directory_io(path, mask)
|
|
28
|
+
raise "inotify is not available on this system" unless available?
|
|
29
|
+
|
|
30
|
+
fd = inotify_init
|
|
31
|
+
raise "could not initialize file watch" if fd.negative?
|
|
32
|
+
|
|
33
|
+
watch_id = inotify_add_watch(fd, path, mask)
|
|
34
|
+
if watch_id.negative?
|
|
35
|
+
IO.for_fd(fd, autoclose: true)&.close
|
|
36
|
+
raise "could not watch #{path}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
IO.for_fd(fd, "rb", autoclose: true)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|