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
data/lib/harnex/core.rb
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "set"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module Harnex
|
|
8
|
+
class BinaryNotFound < RuntimeError; end
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def env_value(name, default: nil)
|
|
13
|
+
ENV.fetch(name, default)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOST = env_value("HARNEX_HOST", default: "127.0.0.1")
|
|
17
|
+
DEFAULT_BASE_PORT = Integer(env_value("HARNEX_BASE_PORT", default: "43000"))
|
|
18
|
+
DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
|
|
19
|
+
DEFAULT_ID = "default"
|
|
20
|
+
WATCH_DEBOUNCE_SECONDS = 1.0
|
|
21
|
+
STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
|
|
22
|
+
SESSIONS_DIR = File.join(STATE_DIR, "sessions")
|
|
23
|
+
WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
|
|
24
|
+
ID_ADJECTIVES = %w[
|
|
25
|
+
bold blue calm cool dark dry fast gold gray green
|
|
26
|
+
keen loud mint pale pink red shy slim soft warm
|
|
27
|
+
].freeze
|
|
28
|
+
ID_NOUNS = %w[
|
|
29
|
+
ant bat bee cat cod cow cub doe elk fox
|
|
30
|
+
hen jay kit owl pug ram ray seal wasp yak
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def resolve_repo_root(path = Dir.pwd)
|
|
34
|
+
output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path)
|
|
35
|
+
status.success? ? output.strip : File.expand_path(path)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
File.expand_path(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def repo_key(repo_root)
|
|
41
|
+
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_id(id)
|
|
45
|
+
value = id.to_s.strip
|
|
46
|
+
raise "id is required" if value.empty?
|
|
47
|
+
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def id_key(id)
|
|
52
|
+
normalize_id(id).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cli_key(cli)
|
|
56
|
+
value = cli.to_s.strip.downcase
|
|
57
|
+
return nil if value.empty?
|
|
58
|
+
|
|
59
|
+
value.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def current_session_context(env = ENV)
|
|
63
|
+
session_id = env["HARNEX_SESSION_ID"].to_s.strip
|
|
64
|
+
cli = env["HARNEX_SESSION_CLI"].to_s.strip
|
|
65
|
+
id = env["HARNEX_ID"].to_s.strip
|
|
66
|
+
repo_root = env["HARNEX_SESSION_REPO_ROOT"].to_s.strip
|
|
67
|
+
return nil if session_id.empty? || cli.empty? || id.empty?
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
session_id: session_id,
|
|
71
|
+
cli: cli,
|
|
72
|
+
id: id,
|
|
73
|
+
repo_root: repo_root.empty? ? nil : repo_root
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_relay_message(text, from:, id:, at: Time.now)
|
|
78
|
+
header = "[harnex relay from=#{from} id=#{normalize_id(id)} at=#{at.iso8601}]"
|
|
79
|
+
body = text.to_s
|
|
80
|
+
return header if body.empty?
|
|
81
|
+
|
|
82
|
+
"#{header}\n#{body}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def active_session_ids(repo_root)
|
|
86
|
+
active_sessions(repo_root).map { |session| session["id"].to_s.downcase }.to_set
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def generate_id(repo_root)
|
|
90
|
+
taken = active_session_ids(repo_root)
|
|
91
|
+
ID_ADJECTIVES.product(ID_NOUNS).shuffle.each do |adj, noun|
|
|
92
|
+
candidate = "#{adj}-#{noun}"
|
|
93
|
+
return candidate unless taken.include?(candidate)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
"session-#{SecureRandom.hex(4)}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def registry_path(repo_root, id = DEFAULT_ID)
|
|
100
|
+
FileUtils.mkdir_p(SESSIONS_DIR)
|
|
101
|
+
File.join(SESSIONS_DIR, "#{session_file_slug(repo_root, id)}.json")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def exit_status_path(repo_root, id)
|
|
105
|
+
exit_dir = File.join(STATE_DIR, "exits")
|
|
106
|
+
FileUtils.mkdir_p(exit_dir)
|
|
107
|
+
File.join(exit_dir, "#{session_file_slug(repo_root, id)}.json")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def output_log_path(repo_root, id)
|
|
111
|
+
output_dir = File.join(STATE_DIR, "output")
|
|
112
|
+
FileUtils.mkdir_p(output_dir)
|
|
113
|
+
File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def session_file_slug(repo_root, id)
|
|
117
|
+
slug = id_key(id)
|
|
118
|
+
slug = "default" if slug.empty?
|
|
119
|
+
"#{repo_key(repo_root)}--#{slug}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def active_sessions(repo_root = nil, id: nil, cli: nil)
|
|
123
|
+
FileUtils.mkdir_p(SESSIONS_DIR)
|
|
124
|
+
pattern =
|
|
125
|
+
if repo_root
|
|
126
|
+
File.join(SESSIONS_DIR, "#{repo_key(repo_root)}--*.json")
|
|
127
|
+
else
|
|
128
|
+
File.join(SESSIONS_DIR, "*.json")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
target_id_key = id.nil? ? nil : id_key(id)
|
|
132
|
+
normalized_cli = cli_key(cli)
|
|
133
|
+
|
|
134
|
+
Dir.glob(pattern).sort.filter_map do |path|
|
|
135
|
+
data = JSON.parse(File.read(path))
|
|
136
|
+
if data["pid"] && alive_pid?(data["pid"])
|
|
137
|
+
session = data.merge("registry_path" => path)
|
|
138
|
+
next if target_id_key && id_key(session["id"].to_s) != target_id_key
|
|
139
|
+
next if normalized_cli && cli_key(session_cli(session)) != normalized_cli
|
|
140
|
+
|
|
141
|
+
session
|
|
142
|
+
else
|
|
143
|
+
FileUtils.rm_f(path)
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
rescue JSON::ParserError
|
|
147
|
+
FileUtils.rm_f(path)
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def alive_pid?(pid)
|
|
153
|
+
Process.kill(0, Integer(pid))
|
|
154
|
+
true
|
|
155
|
+
rescue Errno::ESRCH
|
|
156
|
+
false
|
|
157
|
+
rescue Errno::EPERM
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
|
|
162
|
+
sessions = active_sessions(repo_root, id: id, cli: cli)
|
|
163
|
+
return nil unless sessions.length == 1
|
|
164
|
+
|
|
165
|
+
sessions.first
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def tmux_pane_for_pid(pid)
|
|
169
|
+
target_pid = Integer(pid)
|
|
170
|
+
stdout, status = Open3.capture2(
|
|
171
|
+
"tmux", "list-panes", "-a", "-F",
|
|
172
|
+
"\#{pane_id}\t\#{pane_pid}\t\#{session_name}\t\#{window_name}"
|
|
173
|
+
)
|
|
174
|
+
return nil unless status.success?
|
|
175
|
+
|
|
176
|
+
panes = stdout.each_line.filter_map do |line|
|
|
177
|
+
pane_id, pane_pid, session_name, window_name = line.chomp.split("\t", 4)
|
|
178
|
+
next if pane_id.to_s.empty?
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
target: pane_id,
|
|
182
|
+
pane_id: pane_id,
|
|
183
|
+
pane_pid: pane_pid.to_i,
|
|
184
|
+
session_name: session_name,
|
|
185
|
+
window_name: window_name
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
pane_pids = panes.map { |p| p[:pane_pid] }.to_set
|
|
190
|
+
|
|
191
|
+
# Direct match first
|
|
192
|
+
matches = panes.select { |p| p[:pane_pid] == target_pid }
|
|
193
|
+
|
|
194
|
+
# If no direct match, walk up the process tree from target_pid
|
|
195
|
+
# to find an ancestor that is a tmux pane root process.
|
|
196
|
+
if matches.empty?
|
|
197
|
+
ancestor = parent_pid(target_pid)
|
|
198
|
+
while ancestor && ancestor > 1
|
|
199
|
+
if pane_pids.include?(ancestor)
|
|
200
|
+
matches = panes.select { |p| p[:pane_pid] == ancestor }
|
|
201
|
+
break
|
|
202
|
+
end
|
|
203
|
+
ancestor = parent_pid(ancestor)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return nil unless matches.length == 1
|
|
208
|
+
|
|
209
|
+
result = matches.first
|
|
210
|
+
result.delete(:pane_pid)
|
|
211
|
+
result
|
|
212
|
+
rescue ArgumentError, Errno::ENOENT
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parent_pid(pid)
|
|
217
|
+
stat = File.read("/proc/#{pid}/stat")
|
|
218
|
+
# Field 4 is ppid (fields are space-separated, field 1 is pid,
|
|
219
|
+
# field 2 is (comm) which may contain spaces, field 3 is state, field 4 is ppid)
|
|
220
|
+
parts = stat.match(/\A\d+\s+\(.*?\)\s+\S+\s+(\d+)/)
|
|
221
|
+
parts ? parts[1].to_i : nil
|
|
222
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def write_registry(path, payload)
|
|
227
|
+
tmp = "#{path}.tmp.#{Process.pid}"
|
|
228
|
+
File.write(tmp, JSON.pretty_generate(payload))
|
|
229
|
+
File.rename(tmp, path)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
|
|
233
|
+
if requested_port
|
|
234
|
+
return requested_port if port_available?(host, requested_port)
|
|
235
|
+
|
|
236
|
+
raise "port #{requested_port} is already in use on #{host}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
seed = Digest::SHA256.hexdigest("#{repo_root}\0#{normalize_id(id)}").to_i(16)
|
|
240
|
+
offset = seed % DEFAULT_PORT_SPAN
|
|
241
|
+
|
|
242
|
+
DEFAULT_PORT_SPAN.times do |index|
|
|
243
|
+
port = DEFAULT_BASE_PORT + ((offset + index) % DEFAULT_PORT_SPAN)
|
|
244
|
+
return port if port_available?(host, port)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
raise "could not find a free port in #{DEFAULT_BASE_PORT}-#{DEFAULT_BASE_PORT + DEFAULT_PORT_SPAN - 1}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def port_available?(host, port)
|
|
251
|
+
server = TCPServer.new(host, port)
|
|
252
|
+
server.close
|
|
253
|
+
true
|
|
254
|
+
rescue Errno::EADDRINUSE, Errno::EACCES
|
|
255
|
+
false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def build_adapter(cli, argv)
|
|
259
|
+
raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
|
|
260
|
+
|
|
261
|
+
Adapters.build(cli, argv)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def session_cli(session)
|
|
265
|
+
(session["cli"] || Array(session["command"]).first).to_s
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def build_watch_config(path, repo_root)
|
|
269
|
+
return nil if path.nil?
|
|
270
|
+
|
|
271
|
+
raise "file watch is unsupported on this system" unless Watcher.available?
|
|
272
|
+
|
|
273
|
+
display_path = path.to_s.strip
|
|
274
|
+
raise ArgumentError, "--watch requires a value" if display_path.empty?
|
|
275
|
+
|
|
276
|
+
absolute_path = File.expand_path(display_path, repo_root)
|
|
277
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
|
278
|
+
|
|
279
|
+
WatchConfig.new(
|
|
280
|
+
absolute_path: absolute_path,
|
|
281
|
+
display_path: display_path,
|
|
282
|
+
hook_message: "file-change-hook: read #{display_path}",
|
|
283
|
+
debounce_seconds: WATCH_DEBOUNCE_SECONDS
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
class ApiServer
|
|
6
|
+
def initialize(session)
|
|
7
|
+
@session = session
|
|
8
|
+
@server = TCPServer.new(session.host, session.port)
|
|
9
|
+
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
10
|
+
@thread = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
@thread = Thread.new do
|
|
15
|
+
loop do
|
|
16
|
+
socket = @server.accept
|
|
17
|
+
Thread.new(socket) { |client| handle(client) }
|
|
18
|
+
rescue IOError, Errno::EBADF
|
|
19
|
+
break
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stop
|
|
25
|
+
@server.close
|
|
26
|
+
@thread&.join(1)
|
|
27
|
+
rescue IOError, Errno::EBADF
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def handle(client)
|
|
34
|
+
request_line = client.gets("\r\n")
|
|
35
|
+
return unless request_line
|
|
36
|
+
|
|
37
|
+
method, target, = request_line.split(" ", 3)
|
|
38
|
+
headers = {}
|
|
39
|
+
while (line = client.gets("\r\n"))
|
|
40
|
+
line = line.strip
|
|
41
|
+
break if line.empty?
|
|
42
|
+
|
|
43
|
+
key, value = line.split(":", 2)
|
|
44
|
+
headers[key.downcase] = value.to_s.strip
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
body = +""
|
|
48
|
+
length = headers.fetch("content-length", "0").to_i
|
|
49
|
+
body = client.read(length) if length.positive?
|
|
50
|
+
|
|
51
|
+
path = target.to_s.split("?", 2).first
|
|
52
|
+
|
|
53
|
+
case [method, path]
|
|
54
|
+
when ["GET", "/health"], ["GET", "/status"]
|
|
55
|
+
return unauthorized(client) unless authorized?(headers)
|
|
56
|
+
|
|
57
|
+
json(client, 200, @session.status_payload)
|
|
58
|
+
when ["GET", "/inbox"]
|
|
59
|
+
return unauthorized(client) unless authorized?(headers)
|
|
60
|
+
|
|
61
|
+
json(client, 200, ok: true, messages: @session.inbox.pending_messages)
|
|
62
|
+
when ["DELETE", "/inbox"]
|
|
63
|
+
return unauthorized(client) unless authorized?(headers)
|
|
64
|
+
|
|
65
|
+
json(client, 200, ok: true, cleared: @session.inbox.clear)
|
|
66
|
+
when ["POST", "/stop"]
|
|
67
|
+
return unauthorized(client) unless authorized?(headers)
|
|
68
|
+
|
|
69
|
+
json(client, 200, @session.inject_stop)
|
|
70
|
+
when ["POST", "/send"]
|
|
71
|
+
return unauthorized(client) unless authorized?(headers)
|
|
72
|
+
|
|
73
|
+
payload = parse_send_body(headers, body)
|
|
74
|
+
if payload[:mode] == :adapter
|
|
75
|
+
return json(client, 400, ok: false, error: "text is required") if payload[:text].to_s.empty? && !payload[:enter_only]
|
|
76
|
+
|
|
77
|
+
result = @session.inbox.enqueue(
|
|
78
|
+
text: payload[:text],
|
|
79
|
+
submit: payload[:submit],
|
|
80
|
+
enter_only: payload[:enter_only],
|
|
81
|
+
force: payload[:force]
|
|
82
|
+
)
|
|
83
|
+
http_code = result.delete(:http_status) || 200
|
|
84
|
+
json(client, http_code, result)
|
|
85
|
+
else
|
|
86
|
+
return json(client, 400, ok: false, error: "text is required") if payload[:text].to_s.empty?
|
|
87
|
+
|
|
88
|
+
json(client, 200, @session.inject(payload[:text], newline: payload[:newline]))
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
if method == "GET" && path =~ %r{\A/messages/([a-f0-9]+)\z}
|
|
92
|
+
return unauthorized(client) unless authorized?(headers)
|
|
93
|
+
|
|
94
|
+
msg_id = Regexp.last_match(1)
|
|
95
|
+
msg = @session.inbox.message_status(msg_id)
|
|
96
|
+
if msg
|
|
97
|
+
json(client, 200, msg)
|
|
98
|
+
else
|
|
99
|
+
json(client, 404, ok: false, error: "message not found")
|
|
100
|
+
end
|
|
101
|
+
elsif method == "DELETE" && path =~ %r{\A/inbox/([a-f0-9]+)\z}
|
|
102
|
+
return unauthorized(client) unless authorized?(headers)
|
|
103
|
+
|
|
104
|
+
msg_id = Regexp.last_match(1)
|
|
105
|
+
msg = @session.inbox.drop(msg_id)
|
|
106
|
+
if msg
|
|
107
|
+
json(client, 200, ok: true, message: msg)
|
|
108
|
+
else
|
|
109
|
+
json(client, 404, ok: false, error: "message not found")
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
json(client, 404, ok: false, error: "not found")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
json(client, 400, ok: false, error: "invalid json")
|
|
117
|
+
rescue ArgumentError => e
|
|
118
|
+
json(client, 409, ok: false, error: e.message)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
if e.message.start_with?("inbox full")
|
|
121
|
+
json(client, 503, ok: false, error: e.message)
|
|
122
|
+
else
|
|
123
|
+
json(client, 500, ok: false, error: e.message)
|
|
124
|
+
end
|
|
125
|
+
ensure
|
|
126
|
+
client.close unless client.closed?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_send_body(headers, body)
|
|
130
|
+
if headers["content-type"].to_s.include?("application/json")
|
|
131
|
+
parsed = JSON.parse(body.empty? ? "{}" : body)
|
|
132
|
+
if parsed.key?("submit") || parsed.key?("enter_only") || parsed.key?("force")
|
|
133
|
+
{
|
|
134
|
+
mode: :adapter,
|
|
135
|
+
text: parsed["text"].to_s,
|
|
136
|
+
submit: parsed.fetch("submit", true),
|
|
137
|
+
enter_only: parsed.fetch("enter_only", false),
|
|
138
|
+
force: parsed.fetch("force", false)
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
{
|
|
142
|
+
mode: :legacy,
|
|
143
|
+
text: parsed["text"].to_s,
|
|
144
|
+
newline: parsed.fetch("newline", true)
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
{
|
|
149
|
+
mode: :legacy,
|
|
150
|
+
text: body.to_s,
|
|
151
|
+
newline: true
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def authorized?(headers)
|
|
157
|
+
@session.auth_ok?(headers["authorization"].to_s)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def unauthorized(client)
|
|
161
|
+
json(client, 401, ok: false, error: "unauthorized")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def json(client, code, payload)
|
|
165
|
+
body = JSON.generate(payload)
|
|
166
|
+
client.write("HTTP/1.1 #{code} #{http_reason(code)}\r\n")
|
|
167
|
+
client.write("Content-Type: application/json\r\n")
|
|
168
|
+
client.write("Content-Length: #{body.bytesize}\r\n")
|
|
169
|
+
client.write("Connection: close\r\n")
|
|
170
|
+
client.write("\r\n")
|
|
171
|
+
client.write(body)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def http_reason(code)
|
|
175
|
+
{
|
|
176
|
+
200 => "OK",
|
|
177
|
+
202 => "Accepted",
|
|
178
|
+
400 => "Bad Request",
|
|
179
|
+
401 => "Unauthorized",
|
|
180
|
+
409 => "Conflict",
|
|
181
|
+
503 => "Service Unavailable",
|
|
182
|
+
404 => "Not Found",
|
|
183
|
+
500 => "Internal Server Error"
|
|
184
|
+
}.fetch(code, "OK")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
class FileChangeHook
|
|
3
|
+
EVENT_HEADER_SIZE = 16
|
|
4
|
+
WATCH_MASK = Inotify::IN_ATTRIB | Inotify::IN_CLOSE_WRITE | Inotify::IN_CREATE | Inotify::IN_MOVED_TO
|
|
5
|
+
RETRY_SECONDS = 1.0
|
|
6
|
+
IDLE_SLEEP_SECONDS = 0.1
|
|
7
|
+
|
|
8
|
+
def initialize(session, config)
|
|
9
|
+
@session = session
|
|
10
|
+
@config = config
|
|
11
|
+
@target_dir = File.dirname(config.absolute_path)
|
|
12
|
+
@target_name = File.basename(config.absolute_path)
|
|
13
|
+
@buffer = +""
|
|
14
|
+
@buffer.force_encoding(Encoding::BINARY)
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@change_generation = 0
|
|
17
|
+
@delivered_generation = 0
|
|
18
|
+
@last_change_at = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
Thread.new { run }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
reader_thread = Thread.new { watch_loop }
|
|
29
|
+
delivery_loop
|
|
30
|
+
ensure
|
|
31
|
+
reader_thread&.kill
|
|
32
|
+
reader_thread&.join(0.1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def watch_loop
|
|
36
|
+
io = Watcher.directory_io(@target_dir, WATCH_MASK)
|
|
37
|
+
loop do
|
|
38
|
+
chunk = io.readpartial(4096)
|
|
39
|
+
note_change if relevant_change?(chunk)
|
|
40
|
+
rescue EOFError, IOError, Errno::EIO
|
|
41
|
+
break
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
io&.close unless io&.closed?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delivery_loop
|
|
48
|
+
loop do
|
|
49
|
+
generation, delivered_generation, last_change_at = snapshot
|
|
50
|
+
if generation == delivered_generation || last_change_at.nil?
|
|
51
|
+
sleep IDLE_SLEEP_SECONDS
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
remaining = @config.debounce_seconds - (Time.now - last_change_at)
|
|
56
|
+
if remaining.positive?
|
|
57
|
+
sleep [remaining, IDLE_SLEEP_SECONDS].max
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
@session.inbox.enqueue(
|
|
63
|
+
text: @config.hook_message,
|
|
64
|
+
submit: true,
|
|
65
|
+
enter_only: false,
|
|
66
|
+
force: false
|
|
67
|
+
)
|
|
68
|
+
mark_delivered
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
break if e.message == "session is not running"
|
|
71
|
+
|
|
72
|
+
sleep RETRY_SECONDS
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def relevant_change?(chunk)
|
|
78
|
+
@buffer << chunk
|
|
79
|
+
changed = false
|
|
80
|
+
|
|
81
|
+
while @buffer.bytesize >= EVENT_HEADER_SIZE
|
|
82
|
+
_, mask, _, name_length = @buffer.byteslice(0, EVENT_HEADER_SIZE).unpack("iIII")
|
|
83
|
+
event_size = EVENT_HEADER_SIZE + name_length
|
|
84
|
+
break if @buffer.bytesize < event_size
|
|
85
|
+
|
|
86
|
+
name = @buffer.byteslice(EVENT_HEADER_SIZE, name_length).to_s.delete("\0")
|
|
87
|
+
changed ||= name == @target_name && (mask & WATCH_MASK).positive?
|
|
88
|
+
@buffer = @buffer.byteslice(event_size, @buffer.bytesize - event_size).to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
changed
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def note_change
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
@change_generation += 1
|
|
97
|
+
@last_change_at = Time.now
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def snapshot
|
|
102
|
+
@mutex.synchronize { [@change_generation, @delivered_generation, @last_change_at] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mark_delivered
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@delivered_generation = @change_generation
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|