ligarb 0.4.0 → 0.6.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 +4 -4
- data/assets/review.css +682 -0
- data/assets/review.js +684 -0
- data/assets/serve.js +97 -0
- data/assets/style.css +103 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +32 -4
- data/lib/ligarb/claude_runner.rb +313 -0
- data/lib/ligarb/cli.rb +207 -9
- data/lib/ligarb/config.rb +25 -1
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +133 -0
- data/lib/ligarb/server.rb +1218 -0
- data/lib/ligarb/template.rb +7 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +96 -18
- data/templates/book.html.erb +226 -32
- metadata +36 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin Fiddle wrapper for Linux inotify(7).
|
|
4
|
+
# Non-Linux platforms will get LoadError from dlsym lookup.
|
|
5
|
+
|
|
6
|
+
require "fiddle"
|
|
7
|
+
|
|
8
|
+
module Ligarb
|
|
9
|
+
module Inotify
|
|
10
|
+
LIBC = Fiddle.dlopen(nil)
|
|
11
|
+
|
|
12
|
+
InotifyInit1 = Fiddle::Function.new(
|
|
13
|
+
LIBC["inotify_init1"],
|
|
14
|
+
[Fiddle::TYPE_INT],
|
|
15
|
+
Fiddle::TYPE_INT
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
InotifyAddWatch = Fiddle::Function.new(
|
|
19
|
+
LIBC["inotify_add_watch"],
|
|
20
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
|
|
21
|
+
Fiddle::TYPE_INT
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
InotifyRmWatch = Fiddle::Function.new(
|
|
25
|
+
LIBC["inotify_rm_watch"],
|
|
26
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT],
|
|
27
|
+
Fiddle::TYPE_INT
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# inotify event masks
|
|
31
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
32
|
+
IN_CLOEXEC = 0x00080000 # O_CLOEXEC on x86_64
|
|
33
|
+
|
|
34
|
+
# struct inotify_event fixed part: int wd, uint32 mask, uint32 cookie, uint32 len
|
|
35
|
+
EVENT_HEADER_SIZE = 16
|
|
36
|
+
|
|
37
|
+
# Watch a file for writes. Yields each time the file is written and closed.
|
|
38
|
+
# Blocks the calling thread. Caller should wrap in Thread.new.
|
|
39
|
+
def self.watch_file(path, &block)
|
|
40
|
+
fd = InotifyInit1.call(IN_CLOEXEC)
|
|
41
|
+
raise SystemCallError.new("inotify_init1", Fiddle.last_error) if fd < 0
|
|
42
|
+
|
|
43
|
+
io = IO.for_fd(fd, autoclose: true)
|
|
44
|
+
wd = add_watch(fd, path)
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
# IO.select releases GVL while waiting
|
|
48
|
+
IO.select([io])
|
|
49
|
+
buf = io.read_nonblock(4096, exception: false)
|
|
50
|
+
next if buf == :wait_readable || buf.nil?
|
|
51
|
+
|
|
52
|
+
# Parse events — may contain multiple events
|
|
53
|
+
offset = 0
|
|
54
|
+
while offset + EVENT_HEADER_SIZE <= buf.bytesize
|
|
55
|
+
_wd, mask, _cookie, name_len = buf.byteslice(offset, EVENT_HEADER_SIZE).unpack("iIII")
|
|
56
|
+
offset += EVENT_HEADER_SIZE + name_len
|
|
57
|
+
|
|
58
|
+
if mask & IN_CLOSE_WRITE != 0
|
|
59
|
+
block.call
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
ensure
|
|
66
|
+
io&.close rescue nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.add_watch(fd, path)
|
|
70
|
+
wd = InotifyAddWatch.call(fd, path, IN_CLOSE_WRITE)
|
|
71
|
+
raise SystemCallError.new("inotify_add_watch: #{path}", Fiddle.last_error) if wd < 0
|
|
72
|
+
wd
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ligarb
|
|
8
|
+
class ReviewStore
|
|
9
|
+
def initialize(base_dir)
|
|
10
|
+
@dir = File.join(base_dir, ".ligarb", "reviews")
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
FileUtils.mkdir_p(@dir)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
|
|
18
|
+
.compact
|
|
19
|
+
.sort_by { |r| r["created_at"] }
|
|
20
|
+
.map { |r| summary(r) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(id)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
get_unlocked(id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create(context:, message:)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
id = SecureRandom.uuid
|
|
33
|
+
now = Time.now.utc.iso8601
|
|
34
|
+
|
|
35
|
+
review = {
|
|
36
|
+
"id" => id,
|
|
37
|
+
"status" => "open",
|
|
38
|
+
"created_at" => now,
|
|
39
|
+
"context" => context,
|
|
40
|
+
"messages" => [
|
|
41
|
+
{ "role" => "user", "content" => message, "timestamp" => now }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
write_json(id, review)
|
|
46
|
+
review
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_message(id, role:, content:)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
review = get_unlocked(id)
|
|
53
|
+
return nil unless review
|
|
54
|
+
|
|
55
|
+
review["messages"] << {
|
|
56
|
+
"role" => role,
|
|
57
|
+
"content" => content,
|
|
58
|
+
"timestamp" => Time.now.utc.iso8601
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
write_json(id, review)
|
|
62
|
+
review
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update_context_files(id, files)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
review = get_unlocked(id)
|
|
69
|
+
return nil unless review
|
|
70
|
+
|
|
71
|
+
existing = review.dig("context", "uploaded_files") || []
|
|
72
|
+
review["context"]["uploaded_files"] = existing + files
|
|
73
|
+
write_json(id, review)
|
|
74
|
+
review
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def update_status(id, status)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
review = get_unlocked(id)
|
|
81
|
+
return nil unless review
|
|
82
|
+
|
|
83
|
+
review["status"] = status
|
|
84
|
+
write_json(id, review)
|
|
85
|
+
review
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def delete(id)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
path = file_path(id)
|
|
92
|
+
return false unless File.exist?(path)
|
|
93
|
+
File.delete(path)
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def get_unlocked(id)
|
|
101
|
+
path = file_path(id)
|
|
102
|
+
return nil unless File.exist?(path)
|
|
103
|
+
review = read_json(path)
|
|
104
|
+
review["file_path"] = path if review
|
|
105
|
+
review
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def file_path(id)
|
|
109
|
+
File.join(@dir, "#{id}.json")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def read_json(path)
|
|
113
|
+
JSON.parse(File.read(path))
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_json(id, data)
|
|
119
|
+
File.write(file_path(id), JSON.pretty_generate(data))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def summary(review)
|
|
123
|
+
{
|
|
124
|
+
"id" => review["id"],
|
|
125
|
+
"status" => review["status"],
|
|
126
|
+
"created_at" => review["created_at"],
|
|
127
|
+
"context" => review["context"],
|
|
128
|
+
"message_count" => review["messages"].size,
|
|
129
|
+
"last_message" => review["messages"].last
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|