robot_lab-audit 0.2.6
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/.envrc +3 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +2 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +60 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +132 -0
- data/docs/getting_started.md +129 -0
- data/docs/how_it_works.md +193 -0
- data/docs/index.md +44 -0
- data/docs/querying.md +126 -0
- data/examples/01_basic_usage.rb +294 -0
- data/examples/common.rb +54 -0
- data/lib/robot_lab/audit/event_log.rb +102 -0
- data/lib/robot_lab/audit/hook.rb +146 -0
- data/lib/robot_lab/audit/version.rb +7 -0
- data/lib/robot_lab/audit.rb +31 -0
- data/mkdocs.yml +117 -0
- data/sig/robot_lab/audit.rbs +6 -0
- metadata +96 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# robot_lab-audit Demo
|
|
5
|
+
#
|
|
6
|
+
# Shows how robot_lab-audit plugs into RobotLab via the Hook system.
|
|
7
|
+
# Five scenarios are demonstrated:
|
|
8
|
+
#
|
|
9
|
+
# 1. Enable — one call wires up global audit logging
|
|
10
|
+
# 2. Network — a two-robot network run and its audit trail
|
|
11
|
+
# 3. Tools — tool calls captured with args, results, and timing
|
|
12
|
+
# 4. Errors — tool errors recorded with error_class and error_message
|
|
13
|
+
# 5. Scoped — attach the hook to one robot instead of globally
|
|
14
|
+
#
|
|
15
|
+
# No API keys required. Robots and tools use deterministic stubs so the
|
|
16
|
+
# demo is fully reproducible and the focus stays on the audit log.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# bundle exec ruby examples/01_basic_usage.rb
|
|
20
|
+
|
|
21
|
+
require_relative 'common'
|
|
22
|
+
require 'tmpdir'
|
|
23
|
+
require 'json'
|
|
24
|
+
|
|
25
|
+
# ── Fake LLM response ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
FakeResponse = Data.define(:content, :tool_calls, :stop_reason) do
|
|
28
|
+
def initialize(content:, tool_calls: nil, stop_reason: 'end_turn') = super
|
|
29
|
+
def reply = content
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ── Tools ────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
class WordCountTool < RobotLab::Tool
|
|
35
|
+
description 'Counts words in a text string'
|
|
36
|
+
param :text, type: 'string', desc: 'The text to analyse'
|
|
37
|
+
|
|
38
|
+
def execute(text:)
|
|
39
|
+
{ word_count: text.split.size, status: 'ok' }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SentimentTool < RobotLab::Tool
|
|
45
|
+
description 'Returns a sentiment score for a text string'
|
|
46
|
+
param :text, type: 'string', desc: 'The text to score'
|
|
47
|
+
|
|
48
|
+
def execute(text:)
|
|
49
|
+
score = text.downcase.include?('great') ? 0.9 : 0.5
|
|
50
|
+
{ score: score, label: score > 0.7 ? 'positive' : 'neutral' }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FailingTool < RobotLab::Tool
|
|
56
|
+
description 'Always raises a ToolError (for error logging demo)'
|
|
57
|
+
param :reason, type: 'string', desc: 'Failure reason'
|
|
58
|
+
|
|
59
|
+
def execute(reason:)
|
|
60
|
+
raise RobotLab::ToolError, "Simulated failure: #{reason}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ── Build deterministic stub robots ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def stub_robot(name:, system_prompt:, response:, tools: [])
|
|
67
|
+
robot = RobotLab.build(name: name, system_prompt: system_prompt, local_tools: tools)
|
|
68
|
+
robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |_msg = nil, **_kw, &_b|
|
|
69
|
+
FakeResponse.new(content: response)
|
|
70
|
+
end
|
|
71
|
+
robot
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ── Display helpers ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def print_network_runs(runs)
|
|
77
|
+
if runs.empty?
|
|
78
|
+
puts ' (none)'
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
runs.each do |run|
|
|
82
|
+
status = run[:error_class] ? "#{ExOut::RED}FAILED#{ExOut::RESET}" : "#{ExOut::GREEN}OK#{ExOut::RESET}"
|
|
83
|
+
puts " #{ExOut::BOLD}run_id#{ExOut::RESET} #{ExOut::GRAY}#{run[:run_id]}#{ExOut::RESET}"
|
|
84
|
+
kv 'network', run[:network_name]
|
|
85
|
+
kv 'status', status
|
|
86
|
+
kv 'started_at', run[:started_at]
|
|
87
|
+
kv 'duration_ms', "#{run[:duration_ms]}ms"
|
|
88
|
+
kv 'input', run[:input]
|
|
89
|
+
kv('result', run[:result]&.then { |r| r.length > 60 ? "#{r[0..57]}..." : r })
|
|
90
|
+
puts
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def print_events(events, label: nil)
|
|
96
|
+
if events.empty?
|
|
97
|
+
puts ' (none)'
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
puts " #{label}\n\n" if label
|
|
101
|
+
events.each_with_index do |ev, i|
|
|
102
|
+
type_color = ev[:event_type] == 'tool_call' ? ExOut::CYAN : ExOut::GREEN
|
|
103
|
+
error_flag = ev[:error_class] ? " #{ExOut::RED}[#{ev[:error_class]}]#{ExOut::RESET}" : ''
|
|
104
|
+
puts " #{i + 1}. #{type_color}#{ev[:event_type]}#{ExOut::RESET}#{error_flag}"
|
|
105
|
+
kv 'robot', ev[:robot_name]
|
|
106
|
+
kv 'tool', ev[:tool_name] || '—'
|
|
107
|
+
kv 'duration_ms', "#{ev[:duration_ms]}ms"
|
|
108
|
+
kv('input', ev[:input]&.then { |v| v.length > 58 ? "#{v[0..55]}..." : v })
|
|
109
|
+
kv('output', ev[:output]&.then { |v| v.length > 58 ? "#{v[0..55]}..." : v })
|
|
110
|
+
kv 'error', ev[:error_message] if ev[:error_class]
|
|
111
|
+
puts
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ════════════════════════════════════════════════════════════════════════════════
|
|
116
|
+
# Demo
|
|
117
|
+
# ════════════════════════════════════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
DB_DIR = Dir.mktmpdir('robot_lab_audit_demo_')
|
|
120
|
+
DB_PATH = File.join(DB_DIR, 'audit.db')
|
|
121
|
+
|
|
122
|
+
banner 'robot_lab-audit Demo'
|
|
123
|
+
|
|
124
|
+
# ── 1. Enable ────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
section '1. Enabling the Audit Hook'
|
|
127
|
+
|
|
128
|
+
puts <<~TEXT
|
|
129
|
+
One call creates the SQLite database, assigns it to the hook, and
|
|
130
|
+
registers the hook globally with RobotLab.on(Hook).
|
|
131
|
+
|
|
132
|
+
TEXT
|
|
133
|
+
|
|
134
|
+
puts " #{ExOut::BOLD}RobotLab::Audit.enable(db_path: \"~/.robot_lab/audit.db\")#{ExOut::RESET}"
|
|
135
|
+
RobotLab::Audit.enable(db_path: DB_PATH)
|
|
136
|
+
log = RobotLab::Audit::Hook.event_log
|
|
137
|
+
kv 'DB path', DB_PATH, color: ExOut::GRAY
|
|
138
|
+
|
|
139
|
+
# ── 2. Network run ───────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
section '2. Network Run'
|
|
142
|
+
|
|
143
|
+
puts <<~TEXT
|
|
144
|
+
A two-robot content pipeline: an analyst followed by a summariser.
|
|
145
|
+
The audit hook captures the network run and each robot invocation
|
|
146
|
+
automatically — no changes to robot or network setup required.
|
|
147
|
+
|
|
148
|
+
TEXT
|
|
149
|
+
|
|
150
|
+
analyst = stub_robot(
|
|
151
|
+
name: 'analyst',
|
|
152
|
+
system_prompt: 'You analyse text and produce a structured report.',
|
|
153
|
+
response: 'Analysis complete. Positive sentiment. 11 words.'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
summariser = stub_robot(
|
|
157
|
+
name: 'summariser',
|
|
158
|
+
system_prompt: 'You distil analyst output into one sentence.',
|
|
159
|
+
response: 'Great writing — positive tone and concise.'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
network = RobotLab.create_network(name: 'content_pipeline') do
|
|
163
|
+
task :analyse, analyst, depends_on: :none
|
|
164
|
+
task :summarise, summariser, depends_on: :analyse
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
input = 'This is a great piece of writing that deserves careful review.'
|
|
168
|
+
kv 'Input', "\"#{input}\""
|
|
169
|
+
puts
|
|
170
|
+
|
|
171
|
+
network.run(message: input)
|
|
172
|
+
|
|
173
|
+
puts " Network run complete.\n\n"
|
|
174
|
+
|
|
175
|
+
runs = log.network_runs
|
|
176
|
+
print_network_runs(runs)
|
|
177
|
+
|
|
178
|
+
run_id = runs.first&.dig(:run_id)
|
|
179
|
+
if run_id
|
|
180
|
+
puts " Events for run #{ExOut::GRAY}#{run_id}#{ExOut::RESET}:\n\n"
|
|
181
|
+
print_events(log.events_for(run_id))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── 3. Tool calls ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
section '3. Tool Calls'
|
|
187
|
+
|
|
188
|
+
puts <<~TEXT
|
|
189
|
+
Tool invocations are captured in audit_events with event_type "tool_call".
|
|
190
|
+
Each row records the tool name, arguments, result, robot, and timing.
|
|
191
|
+
Tool calls outside a network run have a nil run_id.
|
|
192
|
+
|
|
193
|
+
TEXT
|
|
194
|
+
|
|
195
|
+
word_count = WordCountTool.new
|
|
196
|
+
word_count.instance_variable_set(:@robot, analyst)
|
|
197
|
+
|
|
198
|
+
sentiment = SentimentTool.new
|
|
199
|
+
sentiment.instance_variable_set(:@robot, analyst)
|
|
200
|
+
|
|
201
|
+
word_count.call({ 'text' => input })
|
|
202
|
+
sentiment.call({ 'text' => input })
|
|
203
|
+
|
|
204
|
+
all_tool_events = log.instance_variable_get(:@db)
|
|
205
|
+
.execute("SELECT * FROM audit_events WHERE event_type = 'tool_call'")
|
|
206
|
+
.map do |r|
|
|
207
|
+
keys = %i[id run_id event_type robot_name tool_name input output
|
|
208
|
+
error_class error_message started_at finished_at duration_ms]
|
|
209
|
+
keys.zip(r).to_h
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
print_events(all_tool_events)
|
|
213
|
+
|
|
214
|
+
# ── 4. Error capture ─────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
section '4. Error Capture'
|
|
217
|
+
|
|
218
|
+
puts <<~TEXT
|
|
219
|
+
When a tool raises RobotLab::ToolError, the error is caught, a graceful
|
|
220
|
+
string is returned to the LLM, and the audit log records error_class and
|
|
221
|
+
error_message for post-mortem analysis.
|
|
222
|
+
|
|
223
|
+
TEXT
|
|
224
|
+
|
|
225
|
+
failing = FailingTool.new
|
|
226
|
+
failing.instance_variable_set(:@robot, analyst)
|
|
227
|
+
failing.call({ 'reason' => 'network timeout' })
|
|
228
|
+
|
|
229
|
+
print_events(log.recent_errors, label: 'recent_errors (newest first):')
|
|
230
|
+
|
|
231
|
+
# ── 5. Scoped audit ───────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
section '5. Scoped Audit (Per-Robot)'
|
|
234
|
+
|
|
235
|
+
puts <<~TEXT
|
|
236
|
+
Instead of global registration, attach the hook to specific robots only.
|
|
237
|
+
Clear the global registry first, point Hook.event_log at a fresh database,
|
|
238
|
+
then call robot.on(Hook) on the robots you want to audit. Robots without
|
|
239
|
+
the hook produce no audit entries.
|
|
240
|
+
|
|
241
|
+
TEXT
|
|
242
|
+
|
|
243
|
+
scoped_db_path = File.join(DB_DIR, 'scoped.db')
|
|
244
|
+
scoped_log = RobotLab::Audit::EventLog.new(db_path: scoped_db_path)
|
|
245
|
+
|
|
246
|
+
RobotLab.hooks.clear
|
|
247
|
+
RobotLab::Audit::Hook.event_log = scoped_log
|
|
248
|
+
|
|
249
|
+
audited_robot = stub_robot(
|
|
250
|
+
name: 'audited_robot',
|
|
251
|
+
system_prompt: 'This robot is explicitly audited.',
|
|
252
|
+
response: 'Audited robot response.'
|
|
253
|
+
)
|
|
254
|
+
audited_robot.on(RobotLab::Audit::Hook)
|
|
255
|
+
|
|
256
|
+
silent_robot = stub_robot(
|
|
257
|
+
name: 'silent_robot',
|
|
258
|
+
system_prompt: 'This robot has no audit hook.',
|
|
259
|
+
response: 'Silent robot response — not in any audit log.'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
audited_robot.run('run with auditing')
|
|
263
|
+
silent_robot.run('run without auditing')
|
|
264
|
+
|
|
265
|
+
scoped_events = scoped_log.instance_variable_get(:@db)
|
|
266
|
+
.execute('SELECT * FROM audit_events')
|
|
267
|
+
.map do |r|
|
|
268
|
+
keys = %i[id run_id event_type robot_name tool_name input output
|
|
269
|
+
error_class error_message started_at finished_at duration_ms]
|
|
270
|
+
keys.zip(r).to_h
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
kv 'Scoped DB', scoped_db_path, color: ExOut::GRAY
|
|
274
|
+
kv 'Audited', 'audited_robot (hook registered)'
|
|
275
|
+
kv 'Silent', 'silent_robot (no hook — absent from log)'
|
|
276
|
+
puts
|
|
277
|
+
print_events(scoped_events)
|
|
278
|
+
|
|
279
|
+
# ── Summary ───────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
hr
|
|
282
|
+
puts
|
|
283
|
+
puts "#{ExOut::BOLD}Audit log summary#{ExOut::RESET}"
|
|
284
|
+
|
|
285
|
+
total_events = log.instance_variable_get(:@db)
|
|
286
|
+
.execute('SELECT COUNT(*) FROM audit_events').first.first
|
|
287
|
+
|
|
288
|
+
kv 'Global DB', DB_PATH
|
|
289
|
+
kv 'Network runs', log.network_runs.size.to_s
|
|
290
|
+
kv 'Total events', total_events.to_s
|
|
291
|
+
kv 'Error events', log.recent_errors.size.to_s
|
|
292
|
+
kv 'Scoped DB', scoped_db_path
|
|
293
|
+
kv 'Scoped events', scoped_events.size.to_s
|
|
294
|
+
puts
|
data/examples/common.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
require_relative '../../robot_lab/lib/robot_lab'
|
|
6
|
+
require_relative '../lib/robot_lab/audit'
|
|
7
|
+
|
|
8
|
+
RubyLLM.configure do |c|
|
|
9
|
+
c.logger = Logger.new(File::NULL)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
RobotLab.configure do |c|
|
|
13
|
+
c.logger = Logger.new(File::NULL)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# ── Output helpers ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
module ExOut
|
|
19
|
+
WIDTH = 68
|
|
20
|
+
RESET = "\e[0m"
|
|
21
|
+
BOLD = "\e[1m"
|
|
22
|
+
DIM = "\e[2m"
|
|
23
|
+
CYAN = "\e[36m"
|
|
24
|
+
GREEN = "\e[32m"
|
|
25
|
+
RED = "\e[31m"
|
|
26
|
+
GRAY = "\e[90m"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def banner(title)
|
|
31
|
+
puts
|
|
32
|
+
puts "#{ExOut::BOLD}#{'=' * ExOut::WIDTH}#{ExOut::RESET}"
|
|
33
|
+
puts "#{ExOut::BOLD} #{title}#{ExOut::RESET}"
|
|
34
|
+
puts "#{ExOut::BOLD}#{'=' * ExOut::WIDTH}#{ExOut::RESET}"
|
|
35
|
+
puts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def section(title)
|
|
40
|
+
puts
|
|
41
|
+
tail = '─' * [ExOut::WIDTH - title.length - 4, 2].max
|
|
42
|
+
puts "#{ExOut::BOLD}#{ExOut::CYAN}── #{title} #{tail}#{ExOut::RESET}"
|
|
43
|
+
puts
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def hr
|
|
48
|
+
puts "#{ExOut::DIM}#{'─' * ExOut::WIDTH}#{ExOut::RESET}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def kv(label, value, color: ExOut::RESET)
|
|
53
|
+
puts " #{ExOut::BOLD}#{label.ljust(18)}#{ExOut::RESET}#{color}#{value}#{ExOut::RESET}"
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module RobotLab
|
|
8
|
+
module Audit
|
|
9
|
+
class EventLog
|
|
10
|
+
SCHEMA = <<~SQL
|
|
11
|
+
CREATE TABLE IF NOT EXISTS network_runs (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
run_id TEXT NOT NULL UNIQUE,
|
|
14
|
+
network_name TEXT,
|
|
15
|
+
input TEXT,
|
|
16
|
+
result TEXT,
|
|
17
|
+
error_class TEXT,
|
|
18
|
+
error_message TEXT,
|
|
19
|
+
started_at TEXT NOT NULL,
|
|
20
|
+
finished_at TEXT,
|
|
21
|
+
duration_ms REAL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
run_id TEXT,
|
|
27
|
+
event_type TEXT NOT NULL,
|
|
28
|
+
robot_name TEXT,
|
|
29
|
+
tool_name TEXT,
|
|
30
|
+
input TEXT,
|
|
31
|
+
output TEXT,
|
|
32
|
+
error_class TEXT,
|
|
33
|
+
error_message TEXT,
|
|
34
|
+
started_at TEXT NOT NULL,
|
|
35
|
+
finished_at TEXT,
|
|
36
|
+
duration_ms REAL
|
|
37
|
+
);
|
|
38
|
+
SQL
|
|
39
|
+
|
|
40
|
+
def initialize(db_path:)
|
|
41
|
+
path = File.expand_path(db_path.to_s)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
43
|
+
@db = SQLite3::Database.new(path)
|
|
44
|
+
@db.execute_batch(SCHEMA)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_network_run(run_id:, network_name:, input:, result:, error_class:,
|
|
48
|
+
error_message:, started_at:, finished_at:, duration_ms:)
|
|
49
|
+
@db.execute(
|
|
50
|
+
"INSERT INTO network_runs
|
|
51
|
+
(run_id, network_name, input, result, error_class, error_message,
|
|
52
|
+
started_at, finished_at, duration_ms)
|
|
53
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
54
|
+
[run_id, network_name, input, result, error_class, error_message,
|
|
55
|
+
started_at, finished_at, duration_ms]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_event(run_id:, event_type:, robot_name:, tool_name:, input:, output:,
|
|
60
|
+
error_class:, error_message:, started_at:, finished_at:, duration_ms:)
|
|
61
|
+
@db.execute(
|
|
62
|
+
"INSERT INTO audit_events
|
|
63
|
+
(run_id, event_type, robot_name, tool_name, input, output,
|
|
64
|
+
error_class, error_message, started_at, finished_at, duration_ms)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
66
|
+
[run_id, event_type, robot_name, tool_name, input, output,
|
|
67
|
+
error_class, error_message, started_at, finished_at, duration_ms]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def network_runs(limit: 100)
|
|
72
|
+
rows = @db.execute('SELECT * FROM network_runs ORDER BY started_at DESC LIMIT ?', [limit])
|
|
73
|
+
rows.map { |r| row_to_hash(:network_runs, r) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def events_for(run_id)
|
|
77
|
+
rows = @db.execute('SELECT * FROM audit_events WHERE run_id = ? ORDER BY started_at', [run_id])
|
|
78
|
+
rows.map { |r| row_to_hash(:audit_events, r) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def recent_errors(limit: 50)
|
|
82
|
+
rows = @db.execute(
|
|
83
|
+
'SELECT * FROM audit_events WHERE error_class IS NOT NULL ORDER BY started_at DESC LIMIT ?',
|
|
84
|
+
[limit]
|
|
85
|
+
)
|
|
86
|
+
rows.map { |r| row_to_hash(:audit_events, r) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
NETWORK_RUN_COLUMNS = %i[id run_id network_name input result error_class
|
|
90
|
+
error_message started_at finished_at duration_ms].freeze
|
|
91
|
+
AUDIT_EVENT_COLUMNS = %i[id run_id event_type robot_name tool_name input output
|
|
92
|
+
error_class error_message started_at finished_at duration_ms].freeze
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def row_to_hash(table, row)
|
|
97
|
+
cols = table == :network_runs ? NETWORK_RUN_COLUMNS : AUDIT_EVENT_COLUMNS
|
|
98
|
+
cols.zip(row).to_h
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module Audit
|
|
8
|
+
class Hook < RobotLab::Hook
|
|
9
|
+
self.namespace = :audit
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
attr_accessor :event_log
|
|
13
|
+
|
|
14
|
+
def before_network_run(_ctx)
|
|
15
|
+
return unless event_log
|
|
16
|
+
|
|
17
|
+
set_run_id(SecureRandom.uuid)
|
|
18
|
+
set_network_started_at(Time.now.utc)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def after_network_run(ctx)
|
|
22
|
+
return unless event_log && current_run_id
|
|
23
|
+
|
|
24
|
+
event_log.record_network_run(
|
|
25
|
+
run_id: current_run_id,
|
|
26
|
+
network_name: ctx.network&.name,
|
|
27
|
+
input: extract_network_input(ctx.context),
|
|
28
|
+
result: extract_network_result(ctx.result),
|
|
29
|
+
**error_fields(ctx.error),
|
|
30
|
+
**timing(current_network_started_at)
|
|
31
|
+
)
|
|
32
|
+
ensure
|
|
33
|
+
clear_run_id
|
|
34
|
+
clear_network_started_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def before_run(ctx)
|
|
38
|
+
ctx.local.started_at = Time.now.utc
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def after_run(ctx)
|
|
42
|
+
return unless event_log
|
|
43
|
+
|
|
44
|
+
record_audit_event(
|
|
45
|
+
'robot_run', ctx, ctx.local.started_at,
|
|
46
|
+
nil, safe_json(ctx.request), safe_json(ctx.response), ctx.error
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def before_tool_call(ctx)
|
|
51
|
+
ctx.local.started_at = Time.now.utc
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def after_tool_call(ctx)
|
|
55
|
+
return unless event_log
|
|
56
|
+
|
|
57
|
+
record_audit_event(
|
|
58
|
+
'tool_call', ctx, ctx.local.started_at,
|
|
59
|
+
ctx.tool_name, safe_json(ctx.tool_args), safe_json(ctx.tool_result), ctx.tool_error
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def record_audit_event(event_type, ctx, started, tool_name, input, output, error)
|
|
66
|
+
event_log.record_event(
|
|
67
|
+
run_id: current_run_id,
|
|
68
|
+
event_type: event_type,
|
|
69
|
+
robot_name: ctx.robot&.name,
|
|
70
|
+
tool_name: tool_name,
|
|
71
|
+
input: input,
|
|
72
|
+
output: output,
|
|
73
|
+
**error_fields(error),
|
|
74
|
+
**timing(started)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def timing(started)
|
|
79
|
+
finished = Time.now.utc
|
|
80
|
+
{
|
|
81
|
+
started_at: started&.iso8601(3),
|
|
82
|
+
finished_at: finished.iso8601(3),
|
|
83
|
+
duration_ms: started ? ((finished - started) * 1000).round(2) : nil
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def error_fields(err)
|
|
88
|
+
{
|
|
89
|
+
error_class: err&.class&.name,
|
|
90
|
+
error_message: err&.message
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def set_run_id(id)
|
|
95
|
+
Thread.current[:robot_lab_audit_run_id] = id
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def current_run_id
|
|
99
|
+
Thread.current[:robot_lab_audit_run_id]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clear_run_id
|
|
103
|
+
Thread.current[:robot_lab_audit_run_id] = nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def set_network_started_at(time)
|
|
107
|
+
Thread.current[:robot_lab_audit_network_started_at] = time
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def current_network_started_at
|
|
111
|
+
Thread.current[:robot_lab_audit_network_started_at]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def clear_network_started_at
|
|
115
|
+
Thread.current[:robot_lab_audit_network_started_at] = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def safe_json(value)
|
|
119
|
+
return nil if value.nil?
|
|
120
|
+
|
|
121
|
+
JSON.generate(value)
|
|
122
|
+
rescue JSON::GeneratorError, TypeError
|
|
123
|
+
value.inspect
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def extract_network_input(context)
|
|
127
|
+
return nil if context.nil?
|
|
128
|
+
|
|
129
|
+
msg = context[:message] || context['message']
|
|
130
|
+
msg ? JSON.generate(msg) : safe_json(context)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
context.inspect
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_network_result(result)
|
|
136
|
+
return nil if result.nil?
|
|
137
|
+
|
|
138
|
+
reply = result.value&.reply if result.respond_to?(:value)
|
|
139
|
+
reply ? JSON.generate(reply) : safe_json(result)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
result.inspect
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'audit/version'
|
|
4
|
+
require_relative 'audit/event_log'
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module Audit
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Configure the audit hook with a SQLite database path and register it globally.
|
|
11
|
+
#
|
|
12
|
+
# @param db_path [String] path to the SQLite database file (created if absent)
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
|
|
16
|
+
def self.enable(db_path:)
|
|
17
|
+
unless defined?(RobotLab::Audit::Hook)
|
|
18
|
+
raise Error, 'robot_lab must be loaded before calling RobotLab::Audit.enable'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Hook.event_log = EventLog.new(db_path: db_path)
|
|
22
|
+
RobotLab.on(Hook)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative 'audit/hook' if defined?(RobotLab::Hook)
|
|
28
|
+
|
|
29
|
+
if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
|
|
30
|
+
RobotLab.register_extension(:audit, RobotLab::Audit)
|
|
31
|
+
end
|