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.
@@ -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