notilens 0.3.0 → 0.4.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/README.md +108 -44
- data/bin/notilens +250 -127
- data/lib/notilens/notify.rb +4 -3
- data/lib/notilens/state.rb +50 -11
- data/lib/notilens/version.rb +1 -1
- data/lib/notilens.rb +235 -118
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d1f583f7aad9d8eef202acff467f3a52636763ab184f3a90c5f70f1533bec7b
|
|
4
|
+
data.tar.gz: 22ac7f6f6e05a57a61a79014efa9b4bf97f0d206734e8182b32c8f56995dc100
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef22530fea5eb20fe5f40c307c2149da05f3e48892bded59dc42ef5499df89465d5eefdd77fb700f8c390c7d5d5eb9c0b53261ff64a15a62efeacd47b86f56b0
|
|
7
|
+
data.tar.gz: 791d08cfab8d4ea4824431150a9692ad8f1646131c4fad8d15b71b12cb1b5a857a7b9aaa9a8fa513028a11b8bc79ee2e0b60167873ce55ebe807269f6efeef9c
|
data/README.md
CHANGED
|
@@ -19,11 +19,11 @@ gem "notilens"
|
|
|
19
19
|
```ruby
|
|
20
20
|
require "notilens"
|
|
21
21
|
|
|
22
|
-
nl
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
nl = NotiLens.init("my-agent")
|
|
23
|
+
run = nl.task("report")
|
|
24
|
+
run.start
|
|
25
|
+
run.progress("Processing...")
|
|
26
|
+
run.complete("Done!")
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Credentials
|
|
@@ -34,38 +34,50 @@ Resolved in order:
|
|
|
34
34
|
3. Saved CLI config (`notilens init --agent ...`)
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
|
-
nl = NotiLens.init("my-agent",
|
|
37
|
+
nl = NotiLens.init("my-agent",
|
|
38
|
+
token: "your-token",
|
|
39
|
+
secret: "your-secret",
|
|
40
|
+
state_ttl: 86400 # optional — orphaned state TTL in seconds (default: 86400)
|
|
41
|
+
)
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
## SDK Reference
|
|
41
45
|
|
|
42
46
|
### Task Lifecycle
|
|
43
47
|
|
|
48
|
+
`nl.task(label)` creates a `Run` — an isolated execution context. Multiple concurrent runs of the same label never conflict.
|
|
49
|
+
|
|
44
50
|
```ruby
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
run = nl.task("email") # create a run for the "email" task
|
|
52
|
+
run.queue # optional — pre-start signal
|
|
53
|
+
run.start # begin the run
|
|
54
|
+
|
|
55
|
+
run.progress("Fetching data...")
|
|
56
|
+
run.loop("Processing item 42")
|
|
57
|
+
run.retry
|
|
58
|
+
run.pause("Waiting for rate limit")
|
|
59
|
+
run.resume("Resuming work")
|
|
60
|
+
run.wait("Waiting for tool response")
|
|
61
|
+
run.stop
|
|
62
|
+
run.error("Quota exceeded") # non-fatal, run continues
|
|
63
|
+
|
|
64
|
+
# Terminal — pick one
|
|
65
|
+
run.complete("All done!")
|
|
66
|
+
run.fail("Unrecoverable error")
|
|
67
|
+
run.timeout("Timed out after 5m")
|
|
68
|
+
run.cancel("Cancelled by user")
|
|
69
|
+
run.terminate("Force-killed")
|
|
58
70
|
```
|
|
59
71
|
|
|
60
72
|
### Output & Input Events
|
|
61
73
|
|
|
62
74
|
```ruby
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
run.output_generated("Report ready")
|
|
76
|
+
run.output_failed("Rendering failed")
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
run.input_required("Approve deployment?")
|
|
79
|
+
run.input_approved("Approved")
|
|
80
|
+
run.input_rejected("Rejected")
|
|
69
81
|
```
|
|
70
82
|
|
|
71
83
|
### Metrics
|
|
@@ -73,18 +85,53 @@ nl.input_rejected("Rejected", task_id)
|
|
|
73
85
|
Numeric values accumulate; strings are replaced.
|
|
74
86
|
|
|
75
87
|
```ruby
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
run.metric("tokens", 512)
|
|
89
|
+
run.metric("tokens", 128) # now 640
|
|
90
|
+
run.metric("cost", 0.003)
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
run.reset_metrics("tokens") # reset one key
|
|
93
|
+
run.reset_metrics # reset all
|
|
81
94
|
```
|
|
82
95
|
|
|
96
|
+
### Automatic Timing
|
|
97
|
+
|
|
98
|
+
NotiLens automatically tracks task timing. These fields are included in every notification's `meta` payload when non-zero:
|
|
99
|
+
|
|
100
|
+
| Field | Description |
|
|
101
|
+
|-------|-------------|
|
|
102
|
+
| `total_duration_ms` | Wall-clock time since `start` |
|
|
103
|
+
| `queue_ms` | Time between `queue` and `start` |
|
|
104
|
+
| `pause_ms` | Cumulative time spent paused |
|
|
105
|
+
| `wait_ms` | Cumulative time spent waiting |
|
|
106
|
+
| `active_ms` | Active time (`total − pause − wait`) |
|
|
107
|
+
|
|
83
108
|
### Generic Events
|
|
84
109
|
|
|
85
110
|
```ruby
|
|
86
|
-
nl.
|
|
87
|
-
nl.
|
|
111
|
+
nl.track("custom.event", "Something happened")
|
|
112
|
+
nl.track("custom.event", "With meta", meta: { "key" => "value" })
|
|
113
|
+
|
|
114
|
+
run.track("custom.event", "Run-level event")
|
|
115
|
+
run.track("custom.event", "With meta", meta: { "key" => "value" })
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Full Example
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
require "notilens"
|
|
122
|
+
|
|
123
|
+
nl = NotiLens.init("summarizer", token: "TOKEN", secret: "SECRET")
|
|
124
|
+
run = nl.task("report")
|
|
125
|
+
run.start
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
result = llm.complete(prompt)
|
|
129
|
+
run.metric("tokens", result.usage.total_tokens)
|
|
130
|
+
run.output_generated("Summary ready")
|
|
131
|
+
run.complete("All done!")
|
|
132
|
+
rescue => e
|
|
133
|
+
run.fail(e.message)
|
|
134
|
+
end
|
|
88
135
|
```
|
|
89
136
|
|
|
90
137
|
## CLI
|
|
@@ -105,24 +152,41 @@ notilens remove-agent my-agent
|
|
|
105
152
|
|
|
106
153
|
### Commands
|
|
107
154
|
|
|
108
|
-
|
|
109
|
-
notilens task.start --agent my-agent --task job-123
|
|
110
|
-
notilens task.progress "Fetching" --agent my-agent --task job-123
|
|
111
|
-
notilens task.loop "Item 5" --agent my-agent --task job-123
|
|
112
|
-
notilens task.retry --agent my-agent --task job-123
|
|
113
|
-
notilens task.complete "Done!" --agent my-agent --task job-123
|
|
114
|
-
notilens task.fail "Error" --agent my-agent --task job-123
|
|
115
|
-
|
|
116
|
-
notilens output.generate "Report ready" --agent my-agent --task job-123
|
|
117
|
-
notilens input.required "Approve?" --agent my-agent --task job-123
|
|
118
|
-
|
|
119
|
-
notilens metric tokens=512 cost=0.003 --agent my-agent --task job-123
|
|
120
|
-
notilens metric.reset --agent my-agent --task job-123
|
|
155
|
+
`--task` is a semantic label (e.g. `email`, `report`). Each `task.start` creates an isolated run internally — concurrent executions of the same label never conflict.
|
|
121
156
|
|
|
122
|
-
|
|
157
|
+
```bash
|
|
158
|
+
notilens task.queue --agent my-agent --task email
|
|
159
|
+
notilens task.start --agent my-agent --task email
|
|
160
|
+
notilens task.progress "Fetching data" --agent my-agent --task email
|
|
161
|
+
notilens task.loop "Item 5/100" --agent my-agent --task email
|
|
162
|
+
notilens task.retry --agent my-agent --task email
|
|
163
|
+
notilens task.pause "Rate limited" --agent my-agent --task email
|
|
164
|
+
notilens task.resume "Resuming" --agent my-agent --task email
|
|
165
|
+
notilens task.wait "Awaiting tool" --agent my-agent --task email
|
|
166
|
+
notilens task.stop --agent my-agent --task email
|
|
167
|
+
notilens task.error "Quota hit" --agent my-agent --task email
|
|
168
|
+
notilens task.fail "Fatal error" --agent my-agent --task email
|
|
169
|
+
notilens task.timeout "Timed out" --agent my-agent --task email
|
|
170
|
+
notilens task.cancel "Cancelled" --agent my-agent --task email
|
|
171
|
+
notilens task.terminate "Force stop" --agent my-agent --task email
|
|
172
|
+
notilens task.complete "Done!" --agent my-agent --task email
|
|
173
|
+
|
|
174
|
+
notilens output.generate "Report ready" --agent my-agent --task email
|
|
175
|
+
notilens output.fail "Render failed" --agent my-agent --task email
|
|
176
|
+
notilens input.required "Approve?" --agent my-agent --task email
|
|
177
|
+
notilens input.approve "Approved" --agent my-agent --task email
|
|
178
|
+
notilens input.reject "Rejected" --agent my-agent --task email
|
|
179
|
+
|
|
180
|
+
notilens metric tokens=512 cost=0.003 --agent my-agent --task email
|
|
181
|
+
notilens metric.reset tokens --agent my-agent --task email
|
|
182
|
+
notilens metric.reset --agent my-agent --task email
|
|
183
|
+
|
|
184
|
+
notilens track my.event "Something happened" --agent my-agent
|
|
123
185
|
notilens version
|
|
124
186
|
```
|
|
125
187
|
|
|
188
|
+
`task.start` prints the internal `run_id` to stdout.
|
|
189
|
+
|
|
126
190
|
## Requirements
|
|
127
191
|
|
|
128
192
|
- Ruby 2.7+
|
data/bin/notilens
CHANGED
|
@@ -8,6 +8,7 @@ require "notilens/config"
|
|
|
8
8
|
require "notilens/state"
|
|
9
9
|
require "notilens/notify"
|
|
10
10
|
require "notilens/version"
|
|
11
|
+
require "securerandom"
|
|
11
12
|
|
|
12
13
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -22,21 +23,21 @@ end
|
|
|
22
23
|
|
|
23
24
|
def parse_flags(args)
|
|
24
25
|
f = {
|
|
25
|
-
agent: "",
|
|
26
|
+
agent: "", task_label: "", type: "", meta: {},
|
|
26
27
|
image_url: "", open_url: "", download_url: "",
|
|
27
28
|
tags: "", is_actionable: ""
|
|
28
29
|
}
|
|
29
30
|
i = 0
|
|
30
31
|
while i < args.length
|
|
31
32
|
case args[i]
|
|
32
|
-
when "--agent"
|
|
33
|
-
when "--task"
|
|
34
|
-
when "--type"
|
|
35
|
-
when "--image_url"
|
|
36
|
-
when "--open_url"
|
|
37
|
-
when "--download_url"
|
|
38
|
-
when "--tags"
|
|
39
|
-
when "--is_actionable"then f[:is_actionable] = args[i += 1].to_s
|
|
33
|
+
when "--agent" then f[:agent] = args[i += 1].to_s
|
|
34
|
+
when "--task" then f[:task_label] = args[i += 1].to_s
|
|
35
|
+
when "--type" then f[:type] = args[i += 1].to_s
|
|
36
|
+
when "--image_url" then f[:image_url] = args[i += 1].to_s
|
|
37
|
+
when "--open_url" then f[:open_url] = args[i += 1].to_s
|
|
38
|
+
when "--download_url" then f[:download_url] = args[i += 1].to_s
|
|
39
|
+
when "--tags" then f[:tags] = args[i += 1].to_s
|
|
40
|
+
when "--is_actionable" then f[:is_actionable] = args[i += 1].to_s
|
|
40
41
|
when "--meta"
|
|
41
42
|
kv = args[i += 1].to_s
|
|
42
43
|
eq = kv.index("=")
|
|
@@ -48,35 +49,70 @@ def parse_flags(args)
|
|
|
48
49
|
$stderr.puts "❌ --agent is required"
|
|
49
50
|
exit 1
|
|
50
51
|
end
|
|
51
|
-
f[:task_id] = "task_#{NotiLens::State.now_ms}" if f[:task_id].empty?
|
|
52
52
|
f
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def
|
|
56
|
-
|
|
55
|
+
def resolve_run_id(f)
|
|
56
|
+
if f[:task_label].empty?
|
|
57
|
+
$stderr.puts "❌ --task is required"
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
|
|
61
|
+
if run_id.empty?
|
|
62
|
+
$stderr.puts "❌ No active run for task '#{f[:task_label]}' on agent '#{f[:agent]}'. Run task.start first."
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
|
65
|
+
run_id
|
|
57
66
|
end
|
|
58
67
|
|
|
59
|
-
def
|
|
68
|
+
def gen_run_id
|
|
69
|
+
"run_#{NotiLens::State.now_ms}_#{SecureRandom.hex(4)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def send_notify(event, message, f, run_id)
|
|
60
73
|
conf = NotiLens::Config.get_agent(f[:agent])
|
|
61
74
|
if conf.nil? || conf["token"].to_s.empty? || conf["secret"].to_s.empty?
|
|
62
75
|
$stderr.puts "❌ Agent '#{f[:agent]}' not configured. Run: notilens init --agent #{f[:agent]} --token TOKEN --secret SECRET"
|
|
63
76
|
exit 1
|
|
64
77
|
end
|
|
65
78
|
|
|
66
|
-
sf = NotiLens::State.file(f[:agent],
|
|
79
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
67
80
|
state = NotiLens::State.read(sf)
|
|
68
81
|
|
|
69
82
|
meta = { "agent" => f[:agent] }
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
meta["run_id"] = run_id unless run_id.to_s.empty?
|
|
84
|
+
|
|
85
|
+
unless f[:task_label].empty?
|
|
86
|
+
meta["task"] = f[:task_label]
|
|
87
|
+
now = NotiLens::State.now_ms
|
|
88
|
+
start_time = state["start_time"].to_i
|
|
89
|
+
queued_at = state["queued_at"].to_i
|
|
90
|
+
pause_total = state["pause_total_ms"].to_i
|
|
91
|
+
wait_total = state["wait_total_ms"].to_i
|
|
92
|
+
pause_total += now - state["paused_at"].to_i if state["paused_at"].to_i > 0
|
|
93
|
+
wait_total += now - state["wait_at"].to_i if state["wait_at"].to_i > 0
|
|
94
|
+
total_ms = start_time > 0 ? now - start_time : 0
|
|
95
|
+
queue_ms = (start_time > 0 && queued_at > 0) ? start_time - queued_at : 0
|
|
96
|
+
active_ms = [0, total_ms - pause_total - wait_total].max
|
|
97
|
+
|
|
98
|
+
meta["total_duration_ms"] = total_ms if total_ms > 0
|
|
99
|
+
meta["queue_ms"] = queue_ms if queue_ms > 0
|
|
100
|
+
meta["pause_ms"] = pause_total if pause_total > 0
|
|
101
|
+
meta["wait_ms"] = wait_total if wait_total > 0
|
|
102
|
+
meta["active_ms"] = active_ms if active_ms > 0
|
|
103
|
+
rc = state["retry_count"].to_i; meta["retry_count"] = rc if rc > 0
|
|
104
|
+
lc = state["loop_count"].to_i; meta["loop_count"] = lc if lc > 0
|
|
105
|
+
ec = state["error_count"].to_i; meta["error_count"] = ec if ec > 0
|
|
106
|
+
pc = state["pause_count"].to_i; meta["pause_count"] = pc if pc > 0
|
|
107
|
+
wc = state["wait_count"].to_i; meta["wait_count"] = wc if wc > 0
|
|
108
|
+
end
|
|
109
|
+
|
|
76
110
|
(state["metrics"] || {}).each { |k, v| meta[k] = v }
|
|
77
111
|
f[:meta].each { |k, v| meta[k] = v }
|
|
78
112
|
|
|
79
|
-
title =
|
|
113
|
+
title = f[:task_label].empty? \
|
|
114
|
+
? "#{f[:agent]} | #{event}" \
|
|
115
|
+
: "#{f[:agent]} | #{f[:task_label]} | #{event}"
|
|
80
116
|
|
|
81
117
|
ev_type = %w[info success warning urgent].include?(f[:type]) \
|
|
82
118
|
? f[:type] : NotiLens::Notify.event_type(event)
|
|
@@ -93,7 +129,7 @@ def send_notify(event, message, f)
|
|
|
93
129
|
"message" => message,
|
|
94
130
|
"type" => ev_type,
|
|
95
131
|
"agent" => f[:agent],
|
|
96
|
-
"task_id" => f[:
|
|
132
|
+
"task_id" => f[:task_label],
|
|
97
133
|
"is_actionable" => is_actionable,
|
|
98
134
|
"image_url" => f[:image_url],
|
|
99
135
|
"open_url" => f[:open_url],
|
|
@@ -145,109 +181,181 @@ when "remove-agent"
|
|
|
145
181
|
? puts("✔ Agent '#{rest[0]}' removed") \
|
|
146
182
|
: $stderr.puts("Agent '#{rest[0]}' not found")
|
|
147
183
|
|
|
148
|
-
when "task.
|
|
149
|
-
f
|
|
150
|
-
|
|
184
|
+
when "task.queue"
|
|
185
|
+
f = parse_flags(rest)
|
|
186
|
+
run_id = gen_run_id
|
|
187
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
151
188
|
NotiLens::State.write(sf, {
|
|
152
|
-
"agent"
|
|
153
|
-
"task"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
189
|
+
"agent" => f[:agent],
|
|
190
|
+
"task" => f[:task_label],
|
|
191
|
+
"run_id" => run_id,
|
|
192
|
+
"queued_at" => NotiLens::State.now_ms,
|
|
193
|
+
"retry_count" => 0,
|
|
194
|
+
"loop_count" => 0,
|
|
195
|
+
"error_count" => 0,
|
|
196
|
+
"pause_count" => 0,
|
|
197
|
+
"wait_count" => 0,
|
|
198
|
+
"pause_total_ms" => 0,
|
|
199
|
+
"wait_total_ms" => 0
|
|
157
200
|
})
|
|
158
|
-
|
|
159
|
-
|
|
201
|
+
NotiLens::State.write_pointer(f[:agent], f[:task_label], run_id)
|
|
202
|
+
send_notify("task.queued", "Task queued", f, run_id)
|
|
203
|
+
puts run_id
|
|
204
|
+
|
|
205
|
+
when "task.start"
|
|
206
|
+
f = parse_flags(rest)
|
|
207
|
+
# Reuse run_id from a prior task.queue if available
|
|
208
|
+
run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
|
|
209
|
+
run_id = gen_run_id if run_id.empty?
|
|
210
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
211
|
+
existing = NotiLens::State.read(sf)
|
|
212
|
+
if existing.key?("queued_at")
|
|
213
|
+
NotiLens::State.update(sf, { "start_time" => NotiLens::State.now_ms })
|
|
214
|
+
else
|
|
215
|
+
NotiLens::State.write(sf, {
|
|
216
|
+
"agent" => f[:agent],
|
|
217
|
+
"task" => f[:task_label],
|
|
218
|
+
"run_id" => run_id,
|
|
219
|
+
"start_time" => NotiLens::State.now_ms,
|
|
220
|
+
"retry_count" => 0,
|
|
221
|
+
"loop_count" => 0,
|
|
222
|
+
"error_count" => 0,
|
|
223
|
+
"pause_count" => 0,
|
|
224
|
+
"wait_count" => 0,
|
|
225
|
+
"pause_total_ms" => 0,
|
|
226
|
+
"wait_total_ms" => 0
|
|
227
|
+
})
|
|
228
|
+
end
|
|
229
|
+
NotiLens::State.write_pointer(f[:agent], f[:task_label], run_id)
|
|
230
|
+
send_notify("task.started", "Task started", f, run_id)
|
|
231
|
+
puts run_id
|
|
160
232
|
|
|
161
233
|
when "task.progress"
|
|
162
234
|
pos, rest2 = positional_args(rest)
|
|
163
|
-
f
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
send_notify("task.progress", pos[0].to_s, f)
|
|
167
|
-
puts "⏳ Progress: #{f[:agent]} | #{f[:task_id]}"
|
|
235
|
+
f = parse_flags(rest2)
|
|
236
|
+
run_id = resolve_run_id(f)
|
|
237
|
+
send_notify("task.progress", pos[0].to_s, f, run_id)
|
|
168
238
|
|
|
169
239
|
when "task.loop"
|
|
170
240
|
pos, rest2 = positional_args(rest)
|
|
171
|
-
f
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
NotiLens::State.
|
|
175
|
-
|
|
176
|
-
|
|
241
|
+
f = parse_flags(rest2)
|
|
242
|
+
run_id = resolve_run_id(f)
|
|
243
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
244
|
+
count = NotiLens::State.read(sf)["loop_count"].to_i + 1
|
|
245
|
+
NotiLens::State.update(sf, { "loop_count" => count })
|
|
246
|
+
send_notify("task.loop", pos[0].to_s, f, run_id)
|
|
177
247
|
|
|
178
248
|
when "task.retry"
|
|
179
|
-
f
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
NotiLens::State.
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
f = parse_flags(rest)
|
|
250
|
+
run_id = resolve_run_id(f)
|
|
251
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
252
|
+
count = NotiLens::State.read(sf)["retry_count"].to_i + 1
|
|
253
|
+
NotiLens::State.update(sf, { "retry_count" => count })
|
|
254
|
+
send_notify("task.retry", "Retrying task", f, run_id)
|
|
185
255
|
|
|
186
256
|
when "task.stop"
|
|
187
|
-
f
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
257
|
+
f = parse_flags(rest)
|
|
258
|
+
run_id = resolve_run_id(f)
|
|
259
|
+
send_notify("task.stopped", "Task stopped", f, run_id)
|
|
260
|
+
|
|
261
|
+
when "task.pause"
|
|
262
|
+
pos, rest2 = positional_args(rest)
|
|
263
|
+
f = parse_flags(rest2)
|
|
264
|
+
run_id = resolve_run_id(f)
|
|
265
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
266
|
+
state = NotiLens::State.read(sf)
|
|
267
|
+
NotiLens::State.update(sf, {
|
|
268
|
+
"paused_at" => NotiLens::State.now_ms,
|
|
269
|
+
"pause_count" => state["pause_count"].to_i + 1
|
|
270
|
+
})
|
|
271
|
+
send_notify("task.paused", pos[0].to_s, f, run_id)
|
|
272
|
+
|
|
273
|
+
when "task.resume"
|
|
274
|
+
pos, rest2 = positional_args(rest)
|
|
275
|
+
f = parse_flags(rest2)
|
|
276
|
+
run_id = resolve_run_id(f)
|
|
277
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
278
|
+
state = NotiLens::State.read(sf)
|
|
279
|
+
now = NotiLens::State.now_ms
|
|
280
|
+
updates = {}
|
|
281
|
+
if state["paused_at"].to_i > 0
|
|
282
|
+
updates["pause_total_ms"] = state["pause_total_ms"].to_i + (now - state["paused_at"].to_i)
|
|
283
|
+
updates["paused_at"] = nil
|
|
284
|
+
end
|
|
285
|
+
if state["wait_at"].to_i > 0
|
|
286
|
+
updates["wait_total_ms"] = state["wait_total_ms"].to_i + (now - state["wait_at"].to_i)
|
|
287
|
+
updates["wait_at"] = nil
|
|
288
|
+
end
|
|
289
|
+
NotiLens::State.update(sf, updates) unless updates.empty?
|
|
290
|
+
send_notify("task.resumed", pos[0].to_s, f, run_id)
|
|
291
|
+
|
|
292
|
+
when "task.wait"
|
|
293
|
+
pos, rest2 = positional_args(rest)
|
|
294
|
+
f = parse_flags(rest2)
|
|
295
|
+
run_id = resolve_run_id(f)
|
|
296
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
297
|
+
state = NotiLens::State.read(sf)
|
|
298
|
+
NotiLens::State.update(sf, {
|
|
299
|
+
"wait_at" => NotiLens::State.now_ms,
|
|
300
|
+
"wait_count" => state["wait_count"].to_i + 1
|
|
301
|
+
})
|
|
302
|
+
send_notify("task.waiting", pos[0].to_s, f, run_id)
|
|
193
303
|
|
|
194
304
|
when "task.error"
|
|
195
305
|
pos, rest2 = positional_args(rest)
|
|
196
|
-
f
|
|
197
|
-
|
|
198
|
-
NotiLens::State.
|
|
199
|
-
|
|
306
|
+
f = parse_flags(rest2)
|
|
307
|
+
run_id = resolve_run_id(f)
|
|
308
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
309
|
+
state = NotiLens::State.read(sf)
|
|
310
|
+
NotiLens::State.update(sf, { "last_error" => pos[0].to_s, "error_count" => state["error_count"].to_i + 1 })
|
|
311
|
+
send_notify("task.error", pos[0].to_s, f, run_id)
|
|
200
312
|
$stderr.puts "❌ Error: #{pos[0]}"
|
|
201
313
|
|
|
202
314
|
when "task.fail"
|
|
203
315
|
pos, rest2 = positional_args(rest)
|
|
204
|
-
f
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
NotiLens::State.
|
|
209
|
-
puts "💥 Failed: #{f[:agent]} | #{f[:task_id]}"
|
|
316
|
+
f = parse_flags(rest2)
|
|
317
|
+
run_id = resolve_run_id(f)
|
|
318
|
+
send_notify("task.failed", pos[0].to_s, f, run_id)
|
|
319
|
+
NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
|
|
320
|
+
NotiLens::State.delete_pointer(f[:agent], f[:task_label])
|
|
210
321
|
|
|
211
322
|
when "task.timeout"
|
|
212
323
|
pos, rest2 = positional_args(rest)
|
|
213
|
-
f
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
NotiLens::State.
|
|
218
|
-
puts "⏰ Timeout: #{f[:agent]} | #{f[:task_id]}"
|
|
324
|
+
f = parse_flags(rest2)
|
|
325
|
+
run_id = resolve_run_id(f)
|
|
326
|
+
send_notify("task.timeout", pos[0].to_s, f, run_id)
|
|
327
|
+
NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
|
|
328
|
+
NotiLens::State.delete_pointer(f[:agent], f[:task_label])
|
|
219
329
|
|
|
220
330
|
when "task.cancel"
|
|
221
331
|
pos, rest2 = positional_args(rest)
|
|
222
|
-
f
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
NotiLens::State.
|
|
227
|
-
puts "🚫 Cancelled: #{f[:agent]} | #{f[:task_id]}"
|
|
332
|
+
f = parse_flags(rest2)
|
|
333
|
+
run_id = resolve_run_id(f)
|
|
334
|
+
send_notify("task.cancelled", pos[0].to_s, f, run_id)
|
|
335
|
+
NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
|
|
336
|
+
NotiLens::State.delete_pointer(f[:agent], f[:task_label])
|
|
228
337
|
|
|
229
338
|
when "task.terminate"
|
|
230
339
|
pos, rest2 = positional_args(rest)
|
|
231
|
-
f
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
NotiLens::State.
|
|
236
|
-
puts "⚠ Terminated: #{f[:agent]} | #{f[:task_id]}"
|
|
340
|
+
f = parse_flags(rest2)
|
|
341
|
+
run_id = resolve_run_id(f)
|
|
342
|
+
send_notify("task.terminated", pos[0].to_s, f, run_id)
|
|
343
|
+
NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
|
|
344
|
+
NotiLens::State.delete_pointer(f[:agent], f[:task_label])
|
|
237
345
|
|
|
238
346
|
when "task.complete"
|
|
239
347
|
pos, rest2 = positional_args(rest)
|
|
240
|
-
f
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
NotiLens::State.
|
|
245
|
-
puts "✅ Completed: #{f[:agent]} | #{f[:task_id]}"
|
|
348
|
+
f = parse_flags(rest2)
|
|
349
|
+
run_id = resolve_run_id(f)
|
|
350
|
+
send_notify("task.completed", pos[0].to_s, f, run_id)
|
|
351
|
+
NotiLens::State.delete(NotiLens::State.file(f[:agent], run_id))
|
|
352
|
+
NotiLens::State.delete_pointer(f[:agent], f[:task_label])
|
|
246
353
|
|
|
247
354
|
when "metric"
|
|
248
355
|
pos, rest2 = positional_args(rest)
|
|
249
356
|
f = parse_flags(rest2)
|
|
250
|
-
|
|
357
|
+
run_id = resolve_run_id(f)
|
|
358
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
251
359
|
state = NotiLens::State.read(sf)
|
|
252
360
|
metrics = state["metrics"] || {}
|
|
253
361
|
pos.each do |kv|
|
|
@@ -267,8 +375,9 @@ when "metric"
|
|
|
267
375
|
|
|
268
376
|
when "metric.reset"
|
|
269
377
|
pos, rest2 = positional_args(rest)
|
|
270
|
-
f
|
|
271
|
-
|
|
378
|
+
f = parse_flags(rest2)
|
|
379
|
+
run_id = resolve_run_id(f)
|
|
380
|
+
sf = NotiLens::State.file(f[:agent], run_id)
|
|
272
381
|
if pos[0]
|
|
273
382
|
state = NotiLens::State.read(sf)
|
|
274
383
|
metrics = state["metrics"] || {}
|
|
@@ -282,36 +391,46 @@ when "metric.reset"
|
|
|
282
391
|
|
|
283
392
|
when "output.generate"
|
|
284
393
|
pos, rest2 = positional_args(rest)
|
|
285
|
-
|
|
394
|
+
f = parse_flags(rest2)
|
|
395
|
+
run_id = resolve_run_id(f)
|
|
396
|
+
send_notify("output.generated", pos[0].to_s, f, run_id)
|
|
286
397
|
|
|
287
398
|
when "output.fail"
|
|
288
399
|
pos, rest2 = positional_args(rest)
|
|
289
|
-
|
|
400
|
+
f = parse_flags(rest2)
|
|
401
|
+
run_id = resolve_run_id(f)
|
|
402
|
+
send_notify("output.failed", pos[0].to_s, f, run_id)
|
|
290
403
|
|
|
291
404
|
when "input.required"
|
|
292
405
|
pos, rest2 = positional_args(rest)
|
|
293
|
-
|
|
406
|
+
f = parse_flags(rest2)
|
|
407
|
+
run_id = resolve_run_id(f)
|
|
408
|
+
send_notify("input.required", pos[0].to_s, f, run_id)
|
|
294
409
|
|
|
295
410
|
when "input.approve"
|
|
296
411
|
pos, rest2 = positional_args(rest)
|
|
297
|
-
|
|
412
|
+
f = parse_flags(rest2)
|
|
413
|
+
run_id = resolve_run_id(f)
|
|
414
|
+
send_notify("input.approved", pos[0].to_s, f, run_id)
|
|
298
415
|
|
|
299
416
|
when "input.reject"
|
|
300
417
|
pos, rest2 = positional_args(rest)
|
|
301
|
-
|
|
418
|
+
f = parse_flags(rest2)
|
|
419
|
+
run_id = resolve_run_id(f)
|
|
420
|
+
send_notify("input.rejected", pos[0].to_s, f, run_id)
|
|
302
421
|
|
|
303
|
-
when "
|
|
422
|
+
when "track"
|
|
304
423
|
if rest.length < 2
|
|
305
|
-
$stderr.puts "Usage: notilens
|
|
424
|
+
$stderr.puts "Usage: notilens track <event> <message> --agent <agent>"
|
|
306
425
|
exit 1
|
|
307
426
|
end
|
|
308
|
-
event
|
|
309
|
-
msg
|
|
310
|
-
f
|
|
311
|
-
|
|
312
|
-
NotiLens::State.
|
|
313
|
-
send_notify(event, msg, f)
|
|
314
|
-
puts "📡
|
|
427
|
+
event = rest[0]
|
|
428
|
+
msg = rest[1]
|
|
429
|
+
f = parse_flags(rest[2..])
|
|
430
|
+
# track is agent-level; use pointer if available but don't error if absent
|
|
431
|
+
run_id = NotiLens::State.read_pointer(f[:agent], f[:task_label])
|
|
432
|
+
send_notify(event, msg, f, run_id)
|
|
433
|
+
puts "📡 Tracked: #{event}"
|
|
315
434
|
|
|
316
435
|
when "version"
|
|
317
436
|
puts "NotiLens v#{NotiLens::VERSION}"
|
|
@@ -324,36 +443,40 @@ else
|
|
|
324
443
|
notilens remove-agent <agent>
|
|
325
444
|
|
|
326
445
|
Task Lifecycle:
|
|
327
|
-
notilens task.
|
|
328
|
-
notilens task.
|
|
329
|
-
notilens task.
|
|
330
|
-
notilens task.
|
|
331
|
-
notilens task.
|
|
332
|
-
notilens task.
|
|
333
|
-
notilens task.
|
|
334
|
-
notilens task.
|
|
335
|
-
notilens task.
|
|
336
|
-
notilens task.
|
|
337
|
-
notilens task.
|
|
446
|
+
notilens task.queue --agent <agent> --task <label>
|
|
447
|
+
notilens task.start --agent <agent> --task <label>
|
|
448
|
+
notilens task.progress "msg" --agent <agent> --task <label>
|
|
449
|
+
notilens task.loop "msg" --agent <agent> --task <label>
|
|
450
|
+
notilens task.retry --agent <agent> --task <label>
|
|
451
|
+
notilens task.stop --agent <agent> --task <label>
|
|
452
|
+
notilens task.pause "msg" --agent <agent> --task <label>
|
|
453
|
+
notilens task.resume "msg" --agent <agent> --task <label>
|
|
454
|
+
notilens task.wait "msg" --agent <agent> --task <label>
|
|
455
|
+
notilens task.error "msg" --agent <agent> --task <label>
|
|
456
|
+
notilens task.fail "msg" --agent <agent> --task <label>
|
|
457
|
+
notilens task.timeout "msg" --agent <agent> --task <label>
|
|
458
|
+
notilens task.cancel "msg" --agent <agent> --task <label>
|
|
459
|
+
notilens task.terminate "msg" --agent <agent> --task <label>
|
|
460
|
+
notilens task.complete "msg" --agent <agent> --task <label>
|
|
338
461
|
|
|
339
462
|
Output / Input:
|
|
340
|
-
notilens output.generate "msg" --agent <agent>
|
|
341
|
-
notilens output.fail "msg" --agent <agent>
|
|
342
|
-
notilens input.required "msg" --agent <agent>
|
|
343
|
-
notilens input.approve "msg" --agent <agent>
|
|
344
|
-
notilens input.reject "msg" --agent <agent>
|
|
463
|
+
notilens output.generate "msg" --agent <agent> --task <label>
|
|
464
|
+
notilens output.fail "msg" --agent <agent> --task <label>
|
|
465
|
+
notilens input.required "msg" --agent <agent> --task <label>
|
|
466
|
+
notilens input.approve "msg" --agent <agent> --task <label>
|
|
467
|
+
notilens input.reject "msg" --agent <agent> --task <label>
|
|
345
468
|
|
|
346
469
|
Metrics:
|
|
347
|
-
notilens metric tokens=512 cost=0.003 --agent <agent> --task <
|
|
348
|
-
notilens metric.reset tokens --agent <agent> --task <
|
|
349
|
-
notilens metric.reset --agent <agent> --task <
|
|
470
|
+
notilens metric tokens=512 cost=0.003 --agent <agent> --task <label>
|
|
471
|
+
notilens metric.reset tokens --agent <agent> --task <label>
|
|
472
|
+
notilens metric.reset --agent <agent> --task <label>
|
|
350
473
|
|
|
351
474
|
Generic:
|
|
352
|
-
notilens
|
|
475
|
+
notilens track <event> "msg" --agent <agent>
|
|
353
476
|
|
|
354
477
|
Options:
|
|
355
478
|
--agent <name>
|
|
356
|
-
--task <
|
|
479
|
+
--task <label>
|
|
357
480
|
--type success|warning|urgent|info
|
|
358
481
|
--meta key=value (repeatable)
|
|
359
482
|
--image_url <url>
|
data/lib/notilens/notify.rb
CHANGED
|
@@ -9,7 +9,7 @@ module NotiLens
|
|
|
9
9
|
|
|
10
10
|
SUCCESS_EVENTS = %w[task.completed output.generated input.approved].freeze
|
|
11
11
|
URGENT_EVENTS = %w[task.failed task.timeout task.error task.terminated output.failed].freeze
|
|
12
|
-
WARNING_EVENTS = %w[task.retry task.cancelled input.required input.rejected].freeze
|
|
12
|
+
WARNING_EVENTS = %w[task.retry task.cancelled task.paused task.waiting input.required input.rejected].freeze
|
|
13
13
|
ACTIONABLE_EVENTS = %w[
|
|
14
14
|
task.error task.failed task.timeout task.retry task.loop
|
|
15
15
|
output.failed input.required input.rejected
|
|
@@ -37,11 +37,12 @@ module NotiLens
|
|
|
37
37
|
req.body = body
|
|
38
38
|
|
|
39
39
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
40
|
-
http.use_ssl
|
|
40
|
+
http.use_ssl = true
|
|
41
41
|
http.read_timeout = 10
|
|
42
42
|
http.open_timeout = 10
|
|
43
|
+
|
|
43
44
|
http.request(req)
|
|
44
|
-
rescue
|
|
45
|
+
rescue StandardError
|
|
45
46
|
nil
|
|
46
47
|
end
|
|
47
48
|
end
|
data/lib/notilens/state.rb
CHANGED
|
@@ -3,11 +3,21 @@ require "tmpdir"
|
|
|
3
3
|
|
|
4
4
|
module NotiLens
|
|
5
5
|
module State
|
|
6
|
-
def self.
|
|
7
|
-
|
|
6
|
+
def self.os_user
|
|
7
|
+
ENV["USER"] || ENV["USERNAME"] || ""
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.file(agent, run_id)
|
|
11
|
+
user = os_user
|
|
8
12
|
agent = agent.gsub(File::SEPARATOR, "_")
|
|
9
|
-
|
|
10
|
-
File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{
|
|
13
|
+
run_id = run_id.gsub(File::SEPARATOR, "_")
|
|
14
|
+
File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{run_id}.json")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.pointer_file(agent, label)
|
|
18
|
+
user = os_user
|
|
19
|
+
safe_label = label.gsub("/", "_").gsub("\\", "_")
|
|
20
|
+
File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{safe_label}.ptr")
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
def self.read(path)
|
|
@@ -18,8 +28,10 @@ module NotiLens
|
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def self.write(path, data)
|
|
21
|
-
|
|
22
|
-
File.
|
|
31
|
+
tmp = path + ".tmp"
|
|
32
|
+
File.write(tmp, JSON.pretty_generate(data))
|
|
33
|
+
File.chmod(0600, tmp)
|
|
34
|
+
File.rename(tmp, path)
|
|
23
35
|
rescue
|
|
24
36
|
nil
|
|
25
37
|
end
|
|
@@ -36,11 +48,38 @@ module NotiLens
|
|
|
36
48
|
nil
|
|
37
49
|
end
|
|
38
50
|
|
|
39
|
-
def self.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
def self.read_pointer(agent, label)
|
|
52
|
+
path = pointer_file(agent, label)
|
|
53
|
+
return "" unless File.exist?(path)
|
|
54
|
+
File.read(path).strip
|
|
55
|
+
rescue
|
|
56
|
+
""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.write_pointer(agent, label, run_id)
|
|
60
|
+
File.write(pointer_file(agent, label), run_id)
|
|
61
|
+
rescue
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.delete_pointer(agent, label)
|
|
66
|
+
path = pointer_file(agent, label)
|
|
67
|
+
File.delete(path) if File.exist?(path)
|
|
68
|
+
rescue
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.cleanup_stale(agent, state_ttl_seconds)
|
|
73
|
+
user = os_user
|
|
74
|
+
tmp = Dir.tmpdir
|
|
75
|
+
cutoff = Time.now.to_f - state_ttl_seconds
|
|
76
|
+
prefix = "notilens_#{user}_#{agent}_"
|
|
77
|
+
Dir.entries(tmp).each do |name|
|
|
78
|
+
next unless name.start_with?(prefix)
|
|
79
|
+
next unless name.end_with?(".json") || name.end_with?(".ptr")
|
|
80
|
+
path = File.join(tmp, name)
|
|
81
|
+
File.delete(path) if File.mtime(path).to_f < cutoff rescue nil
|
|
82
|
+
end
|
|
44
83
|
end
|
|
45
84
|
|
|
46
85
|
def self.now_ms
|
data/lib/notilens/version.rb
CHANGED
data/lib/notilens.rb
CHANGED
|
@@ -4,24 +4,15 @@ require_relative "notilens/state"
|
|
|
4
4
|
require_relative "notilens/notify"
|
|
5
5
|
|
|
6
6
|
module NotiLens
|
|
7
|
-
class
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@
|
|
7
|
+
class Run
|
|
8
|
+
attr_reader :run_id, :label
|
|
9
|
+
|
|
10
|
+
def initialize(agent_obj, label, run_id)
|
|
11
|
+
@agent = agent_obj
|
|
12
|
+
@label = label
|
|
13
|
+
@run_id = run_id
|
|
14
|
+
@sf = State.file(agent_obj.agent_name, run_id)
|
|
12
15
|
@metrics = {}
|
|
13
|
-
|
|
14
|
-
if @token.empty? || @secret.empty?
|
|
15
|
-
conf = Config.get_agent(agent) || {}
|
|
16
|
-
@token = conf["token"] if @token.empty?
|
|
17
|
-
@secret = conf["secret"] if @secret.empty?
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
if @token.to_s.empty? || @secret.to_s.empty?
|
|
21
|
-
raise "NotiLens: token and secret are required. Pass them directly, " \
|
|
22
|
-
"set NOTILENS_TOKEN/NOTILENS_SECRET env vars, or run: " \
|
|
23
|
-
"notilens init --agent #{agent} --token TOKEN --secret SECRET"
|
|
24
|
-
end
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
# ── Metrics ────────────────────────────────────────────────────────────────
|
|
@@ -40,142 +31,259 @@ module NotiLens
|
|
|
40
31
|
self
|
|
41
32
|
end
|
|
42
33
|
|
|
43
|
-
# ──
|
|
44
|
-
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
34
|
+
# ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def queue
|
|
37
|
+
State.write(@sf, {
|
|
38
|
+
"agent" => @agent.agent_name,
|
|
39
|
+
"task" => @label,
|
|
40
|
+
"run_id" => @run_id,
|
|
41
|
+
"queued_at" => State.now_ms,
|
|
42
|
+
"retry_count" => 0,
|
|
43
|
+
"loop_count" => 0,
|
|
44
|
+
"error_count" => 0,
|
|
45
|
+
"pause_count" => 0,
|
|
46
|
+
"wait_count" => 0,
|
|
47
|
+
"pause_total_ms" => 0,
|
|
48
|
+
"wait_total_ms" => 0
|
|
54
49
|
})
|
|
55
|
-
send_event("task.
|
|
56
|
-
|
|
50
|
+
send_event("task.queued", "Task queued")
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start
|
|
55
|
+
now = State.now_ms
|
|
56
|
+
existing = State.read(@sf)
|
|
57
|
+
if existing.key?("queued_at")
|
|
58
|
+
State.update(@sf, { "start_time" => now })
|
|
59
|
+
else
|
|
60
|
+
State.write(@sf, {
|
|
61
|
+
"agent" => @agent.agent_name,
|
|
62
|
+
"task" => @label,
|
|
63
|
+
"run_id" => @run_id,
|
|
64
|
+
"start_time" => now,
|
|
65
|
+
"retry_count" => 0,
|
|
66
|
+
"loop_count" => 0,
|
|
67
|
+
"error_count" => 0,
|
|
68
|
+
"pause_count" => 0,
|
|
69
|
+
"wait_count" => 0,
|
|
70
|
+
"pause_total_ms" => 0,
|
|
71
|
+
"wait_total_ms" => 0
|
|
72
|
+
})
|
|
73
|
+
end
|
|
74
|
+
send_event("task.started", "Task started")
|
|
75
|
+
self
|
|
57
76
|
end
|
|
58
77
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
def progress(message) = send_event("task.progress", message)
|
|
79
|
+
|
|
80
|
+
def loop(message)
|
|
81
|
+
s = State.read(@sf)
|
|
82
|
+
State.update(@sf, { "loop_count" => s["loop_count"].to_i + 1 })
|
|
83
|
+
send_event("task.loop", message)
|
|
63
84
|
end
|
|
64
85
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
State.update(sf, { "duration_ms" => State.calc_duration(sf), "loop_count" => count })
|
|
70
|
-
send_event("task.loop", message, task_id)
|
|
86
|
+
def retry
|
|
87
|
+
s = State.read(@sf)
|
|
88
|
+
State.update(@sf, { "retry_count" => s["retry_count"].to_i + 1 })
|
|
89
|
+
send_event("task.retry", "Retrying task")
|
|
71
90
|
end
|
|
72
91
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
def pause(message)
|
|
93
|
+
s = State.read(@sf)
|
|
94
|
+
State.update(@sf, {
|
|
95
|
+
"paused_at" => State.now_ms,
|
|
96
|
+
"pause_count" => s["pause_count"].to_i + 1
|
|
97
|
+
})
|
|
98
|
+
send_event("task.paused", message)
|
|
79
99
|
end
|
|
80
100
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
def resume(message)
|
|
102
|
+
s = State.read(@sf)
|
|
103
|
+
now = State.now_ms
|
|
104
|
+
updates = {}
|
|
105
|
+
if s["paused_at"].to_i > 0
|
|
106
|
+
updates["pause_total_ms"] = s["pause_total_ms"].to_i + (now - s["paused_at"].to_i)
|
|
107
|
+
updates["paused_at"] = nil
|
|
108
|
+
end
|
|
109
|
+
if s["wait_at"].to_i > 0
|
|
110
|
+
updates["wait_total_ms"] = s["wait_total_ms"].to_i + (now - s["wait_at"].to_i)
|
|
111
|
+
updates["wait_at"] = nil
|
|
112
|
+
end
|
|
113
|
+
State.update(@sf, updates) unless updates.empty?
|
|
114
|
+
send_event("task.resumed", message)
|
|
85
115
|
end
|
|
86
116
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
State.update(sf, {
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
def wait(message)
|
|
118
|
+
s = State.read(@sf)
|
|
119
|
+
State.update(@sf, {
|
|
120
|
+
"wait_at" => State.now_ms,
|
|
121
|
+
"wait_count" => s["wait_count"].to_i + 1
|
|
122
|
+
})
|
|
123
|
+
send_event("task.waiting", message)
|
|
92
124
|
end
|
|
93
125
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
State.
|
|
126
|
+
def stop = send_event("task.stopped", "Task stopped")
|
|
127
|
+
|
|
128
|
+
def error(message)
|
|
129
|
+
s = State.read(@sf)
|
|
130
|
+
State.update(@sf, { "last_error" => message, "error_count" => s["error_count"].to_i + 1 })
|
|
131
|
+
send_event("task.error", message)
|
|
99
132
|
end
|
|
100
133
|
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
send_event("task.timeout", message, task_id)
|
|
105
|
-
State.delete(sf)
|
|
134
|
+
def complete(message)
|
|
135
|
+
send_event("task.completed", message)
|
|
136
|
+
terminal
|
|
106
137
|
end
|
|
107
138
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
send_event("task.cancelled", message, task_id)
|
|
112
|
-
State.delete(sf)
|
|
139
|
+
def fail(message)
|
|
140
|
+
send_event("task.failed", message)
|
|
141
|
+
terminal
|
|
113
142
|
end
|
|
114
143
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
send_event("task.stopped", "Task stopped", task_id)
|
|
144
|
+
def timeout(message)
|
|
145
|
+
send_event("task.timeout", message)
|
|
146
|
+
terminal
|
|
119
147
|
end
|
|
120
148
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
send_event("task.terminated", message, task_id)
|
|
125
|
-
State.delete(sf)
|
|
149
|
+
def cancel(message)
|
|
150
|
+
send_event("task.cancelled", message)
|
|
151
|
+
terminal
|
|
126
152
|
end
|
|
127
153
|
|
|
128
|
-
|
|
154
|
+
def terminate(message)
|
|
155
|
+
send_event("task.terminated", message)
|
|
156
|
+
terminal
|
|
157
|
+
end
|
|
129
158
|
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
# ── Input / Output ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def input_required(message) = send_event("input.required", message)
|
|
162
|
+
def input_approved(message) = send_event("input.approved", message)
|
|
163
|
+
def input_rejected(message) = send_event("input.rejected", message)
|
|
164
|
+
def output_generated(message) = send_event("output.generated", message)
|
|
165
|
+
def output_failed(message) = send_event("output.failed", message)
|
|
166
|
+
|
|
167
|
+
def track(event, message, meta: {})
|
|
168
|
+
send_event(event, message, extra_meta: meta)
|
|
132
169
|
end
|
|
133
170
|
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def send_event(event, message, extra_meta: {})
|
|
174
|
+
@agent.send_payload(event, message, @run_id, @label, @sf, @metrics, extra_meta)
|
|
136
175
|
end
|
|
137
176
|
|
|
138
|
-
def
|
|
139
|
-
|
|
177
|
+
def terminal
|
|
178
|
+
State.delete(@sf)
|
|
179
|
+
State.delete_pointer(@agent.agent_name, @label)
|
|
140
180
|
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
class Client
|
|
184
|
+
attr_reader :agent_name
|
|
141
185
|
|
|
142
|
-
|
|
186
|
+
def initialize(agent, token: nil, secret: nil, state_ttl: 86400)
|
|
187
|
+
@agent_name = agent
|
|
188
|
+
@token = token || ENV["NOTILENS_TOKEN"] || ""
|
|
189
|
+
@secret = secret || ENV["NOTILENS_SECRET"] || ""
|
|
190
|
+
@state_ttl = state_ttl
|
|
191
|
+
@metrics = {}
|
|
143
192
|
|
|
144
|
-
|
|
145
|
-
|
|
193
|
+
if @token.empty? || @secret.empty?
|
|
194
|
+
conf = Config.get_agent(agent) || {}
|
|
195
|
+
@token = conf["token"] if @token.empty?
|
|
196
|
+
@secret = conf["secret"] if @secret.empty?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if @token.to_s.empty? || @secret.to_s.empty?
|
|
200
|
+
raise "NotiLens: token and secret are required. Pass them directly, " \
|
|
201
|
+
"set NOTILENS_TOKEN/NOTILENS_SECRET env vars, or run: " \
|
|
202
|
+
"notilens init --agent #{agent} --token TOKEN --secret SECRET"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
State.cleanup_stale(agent, state_ttl)
|
|
206
|
+
|
|
207
|
+
# Single background worker — one thread shared across all sends
|
|
208
|
+
@_queue = SizedQueue.new(256)
|
|
209
|
+
token_w = @token
|
|
210
|
+
secret_w = @secret
|
|
211
|
+
@_worker = Thread.new do
|
|
212
|
+
while (job = @_queue.pop)
|
|
213
|
+
Notify.send(token_w, secret_w, job) rescue nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
146
216
|
end
|
|
147
217
|
|
|
148
|
-
|
|
149
|
-
|
|
218
|
+
# ── Task factory ───────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def task(label)
|
|
221
|
+
State.cleanup_stale(@agent_name, @state_ttl)
|
|
222
|
+
Run.new(self, label, gen_run_id)
|
|
150
223
|
end
|
|
151
224
|
|
|
152
|
-
# ──
|
|
225
|
+
# ── Agent-level metrics ────────────────────────────────────────────────────
|
|
153
226
|
|
|
154
|
-
def
|
|
155
|
-
|
|
227
|
+
def metric(key, value)
|
|
228
|
+
if value.is_a?(Numeric) && @metrics[key].is_a?(Numeric)
|
|
229
|
+
@metrics[key] += value
|
|
230
|
+
else
|
|
231
|
+
@metrics[key] = value
|
|
232
|
+
end
|
|
233
|
+
self
|
|
156
234
|
end
|
|
157
235
|
|
|
158
|
-
|
|
236
|
+
def reset_metrics(key = nil)
|
|
237
|
+
key ? @metrics.delete(key) : @metrics.clear
|
|
238
|
+
self
|
|
239
|
+
end
|
|
159
240
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
241
|
+
# ── Generic track ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def track(event, message, meta: {})
|
|
244
|
+
send_payload(event, message, "", "", "", @metrics, meta)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ── Internal (called by Run) ───────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def send_payload(event, message, run_id, label, state_file, run_metrics, extra_meta = {})
|
|
250
|
+
title = label.to_s.empty? \
|
|
251
|
+
? "#{@agent_name} | #{event}" \
|
|
252
|
+
: "#{@agent_name} | #{label} | #{event}"
|
|
253
|
+
|
|
254
|
+
meta = { "agent" => @agent_name }
|
|
255
|
+
meta["run_id"] = run_id unless run_id.to_s.empty?
|
|
256
|
+
meta["task"] = label unless label.to_s.empty?
|
|
257
|
+
|
|
258
|
+
unless state_file.to_s.empty?
|
|
259
|
+
state = State.read(state_file)
|
|
260
|
+
now = State.now_ms
|
|
261
|
+
start_time = state["start_time"].to_i
|
|
262
|
+
queued_at = state["queued_at"].to_i
|
|
263
|
+
pause_total = state["pause_total_ms"].to_i
|
|
264
|
+
wait_total = state["wait_total_ms"].to_i
|
|
265
|
+
pause_total += now - state["paused_at"].to_i if state["paused_at"].to_i > 0
|
|
266
|
+
wait_total += now - state["wait_at"].to_i if state["wait_at"].to_i > 0
|
|
267
|
+
|
|
268
|
+
total_ms = start_time > 0 ? now - start_time : 0
|
|
269
|
+
queue_ms = (start_time > 0 && queued_at > 0) ? start_time - queued_at : 0
|
|
270
|
+
active_ms = [0, total_ms - pause_total - wait_total].max
|
|
271
|
+
|
|
272
|
+
meta["total_duration_ms"] = total_ms if total_ms > 0
|
|
273
|
+
meta["queue_ms"] = queue_ms if queue_ms > 0
|
|
274
|
+
meta["pause_ms"] = pause_total if pause_total > 0
|
|
275
|
+
meta["wait_ms"] = wait_total if wait_total > 0
|
|
276
|
+
meta["active_ms"] = active_ms if active_ms > 0
|
|
277
|
+
|
|
278
|
+
rc = state["retry_count"].to_i; meta["retry_count"] = rc if rc > 0
|
|
279
|
+
lc = state["loop_count"].to_i; meta["loop_count"] = lc if lc > 0
|
|
280
|
+
ec = state["error_count"].to_i; meta["error_count"] = ec if ec > 0
|
|
281
|
+
pc = state["pause_count"].to_i; meta["pause_count"] = pc if pc > 0
|
|
282
|
+
wc = state["wait_count"].to_i; meta["wait_count"] = wc if wc > 0
|
|
283
|
+
(state["metrics"] || {}).each { |k, v| meta[k] = v }
|
|
172
284
|
end
|
|
173
285
|
|
|
174
|
-
|
|
175
|
-
meta["duration_ms"] = duration if duration > 0
|
|
176
|
-
meta["retry_count"] = retry_count if retry_count > 0
|
|
177
|
-
meta["loop_count"] = loop_count if loop_count > 0
|
|
178
|
-
@metrics.each { |k, v| meta[k] = v }
|
|
286
|
+
run_metrics.each { |k, v| meta[k] = v }
|
|
179
287
|
meta.merge!(extra_meta)
|
|
180
288
|
|
|
181
289
|
image_url = meta.delete("image_url").to_s
|
|
@@ -191,8 +299,8 @@ module NotiLens
|
|
|
191
299
|
"title" => title,
|
|
192
300
|
"message" => message,
|
|
193
301
|
"type" => Notify.event_type(event),
|
|
194
|
-
"agent" => @
|
|
195
|
-
"task_id" =>
|
|
302
|
+
"agent" => @agent_name,
|
|
303
|
+
"task_id" => label,
|
|
196
304
|
"is_actionable" => is_actionable,
|
|
197
305
|
"image_url" => image_url,
|
|
198
306
|
"open_url" => open_url,
|
|
@@ -202,12 +310,21 @@ module NotiLens
|
|
|
202
310
|
"meta" => meta
|
|
203
311
|
}
|
|
204
312
|
|
|
205
|
-
|
|
313
|
+
# Fire-and-forget — push to background worker queue, never blocks the caller
|
|
314
|
+
@_queue.push(payload)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private
|
|
318
|
+
|
|
319
|
+
def gen_run_id
|
|
320
|
+
"run_#{State.now_ms}_#{SecureRandom.hex(4)}"
|
|
206
321
|
end
|
|
207
322
|
end
|
|
208
323
|
|
|
209
|
-
# Convenience factory
|
|
210
|
-
def self.init(agent, token: nil, secret: nil)
|
|
211
|
-
Client.new(agent, token: token, secret: secret)
|
|
324
|
+
# Convenience factory
|
|
325
|
+
def self.init(agent, token: nil, secret: nil, state_ttl: 86400)
|
|
326
|
+
Client.new(agent, token: token, secret: secret, state_ttl: state_ttl)
|
|
212
327
|
end
|
|
213
328
|
end
|
|
329
|
+
|
|
330
|
+
require "securerandom"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: notilens
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- NotiLens
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Send task started/progress/complete/failed notifications from AI agents,
|
|
14
14
|
Sidekiq workers, and any Ruby application.
|