livetap 0.1.5 → 0.2.1
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.
- package/README.md +140 -129
- package/package.json +16 -7
- package/src/cli/daemon-client.ts +41 -6
- package/src/cli/setup.ts +24 -1
- package/src/cli/start.ts +7 -16
- package/src/cli/status.ts +22 -4
- package/src/cli/stop.ts +19 -36
- package/src/mcp/channel.ts +26 -8
- package/src/mcp/tools.ts +58 -162
- package/src/server/connection-manager.ts +11 -15
- package/src/server/connections/file.ts +7 -8
- package/src/server/connections/mqtt.ts +9 -10
- package/src/server/connections/webhook.ts +9 -10
- package/src/server/connections/websocket.ts +9 -10
- package/src/server/index.ts +14 -19
- package/src/server/stream-store.ts +128 -0
- package/src/server/watchers/engine.ts +7 -2
- package/src/server/watchers/manager.ts +80 -114
- package/src/shared/canonical/cli.ts +144 -0
- package/src/shared/canonical/index.ts +10 -0
- package/src/shared/canonical/meta.ts +222 -0
- package/src/shared/canonical/tools.ts +178 -0
- package/src/shared/catalog-generators.ts +60 -43
- package/src/shared/command-catalog.ts +3 -147
- package/src/server/redis.ts +0 -62
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webhook Ingestor — HTTP endpoint that accepts POSTs and writes
|
|
3
|
-
* payloads to a
|
|
3
|
+
* payloads to a stream with rolling retention.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import type { StreamStore } from '../stream-store.js'
|
|
7
7
|
import type { Subscriber, ConnectionStatus } from '../types.js'
|
|
8
8
|
|
|
9
9
|
export interface WebhookIngestorOpts {
|
|
10
10
|
streamKey: string
|
|
11
|
-
|
|
11
|
+
store: StreamStore
|
|
12
12
|
retentionMs?: number
|
|
13
13
|
onMessage?: () => void
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export class WebhookIngestor implements Subscriber {
|
|
17
17
|
private streamKey: string
|
|
18
|
-
private
|
|
18
|
+
private store: StreamStore
|
|
19
19
|
private retentionMs: number
|
|
20
20
|
private onMessage: (() => void) | undefined
|
|
21
21
|
private state: ConnectionStatus['runtimeState'] = 'disconnected'
|
|
22
22
|
|
|
23
23
|
constructor(opts: WebhookIngestorOpts) {
|
|
24
24
|
this.streamKey = opts.streamKey
|
|
25
|
-
this.
|
|
25
|
+
this.store = opts.store
|
|
26
26
|
this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
|
|
27
27
|
this.onMessage = opts.onMessage
|
|
28
28
|
}
|
|
@@ -39,12 +39,11 @@ export class WebhookIngestor implements Subscriber {
|
|
|
39
39
|
* Ingest a webhook payload. Called by the HTTP API route handler.
|
|
40
40
|
*/
|
|
41
41
|
async ingest(body: string, headers?: Record<string, string>): Promise<void> {
|
|
42
|
-
const fields: string
|
|
43
|
-
if (headers?.['content-type']) fields.
|
|
42
|
+
const fields: Record<string, string> = { payload: body }
|
|
43
|
+
if (headers?.['content-type']) fields.content_type = headers['content-type']
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
|
|
45
|
+
this.store.append(this.streamKey, fields)
|
|
46
|
+
this.store.trim(this.streamKey, Date.now() - this.retentionMs)
|
|
48
47
|
this.onMessage?.()
|
|
49
48
|
}
|
|
50
49
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebSocket Subscriber — connects to a remote WebSocket and writes
|
|
3
|
-
* messages to a
|
|
3
|
+
* messages to a stream with rolling retention + reconnection.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import type { StreamStore } from '../stream-store.js'
|
|
7
7
|
import type { WebSocketConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
|
|
8
8
|
|
|
9
9
|
export interface WsSubscriberOpts {
|
|
10
10
|
config: WebSocketConnectionConfig
|
|
11
11
|
streamKey: string
|
|
12
|
-
|
|
12
|
+
store: StreamStore
|
|
13
13
|
retentionMs?: number
|
|
14
14
|
onMessage?: () => void
|
|
15
15
|
}
|
|
@@ -18,7 +18,7 @@ export class WsSubscriber implements Subscriber {
|
|
|
18
18
|
private ws: WebSocket | null = null
|
|
19
19
|
private config: WebSocketConnectionConfig
|
|
20
20
|
private streamKey: string
|
|
21
|
-
private
|
|
21
|
+
private store: StreamStore
|
|
22
22
|
private retentionMs: number
|
|
23
23
|
private onMessage: (() => void) | undefined
|
|
24
24
|
private state: ConnectionStatus['runtimeState'] = 'disconnected'
|
|
@@ -31,7 +31,7 @@ export class WsSubscriber implements Subscriber {
|
|
|
31
31
|
constructor(opts: WsSubscriberOpts) {
|
|
32
32
|
this.config = opts.config
|
|
33
33
|
this.streamKey = opts.streamKey
|
|
34
|
-
this.
|
|
34
|
+
this.store = opts.store
|
|
35
35
|
this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
|
|
36
36
|
this.onMessage = opts.onMessage
|
|
37
37
|
}
|
|
@@ -65,15 +65,14 @@ export class WsSubscriber implements Subscriber {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
this.ws.onmessage =
|
|
68
|
+
this.ws.onmessage = (event) => {
|
|
69
69
|
try {
|
|
70
70
|
const fields = this.parseMessage(event.data)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
|
|
71
|
+
this.store.append(this.streamKey, fields)
|
|
72
|
+
this.store.trim(this.streamKey, Date.now() - this.retentionMs)
|
|
74
73
|
this.onMessage?.()
|
|
75
74
|
} catch {
|
|
76
|
-
//
|
|
75
|
+
// Write failure
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
78
|
|
package/src/server/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Manages connections, streams, and watchers.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { StreamStore } from './stream-store.js'
|
|
7
7
|
import { ConnectionManager } from './connection-manager.js'
|
|
8
8
|
import { WatcherManager } from './watchers/manager.js'
|
|
9
9
|
import type { WatcherAlert } from './watchers/types.js'
|
|
@@ -11,7 +11,7 @@ import type { StreamEntry } from './types.js'
|
|
|
11
11
|
|
|
12
12
|
const PORT = parseInt(process.env.LIVETAP_PORT || '8788')
|
|
13
13
|
|
|
14
|
-
let
|
|
14
|
+
let store: StreamStore
|
|
15
15
|
let manager: ConnectionManager
|
|
16
16
|
let watchers: WatcherManager
|
|
17
17
|
|
|
@@ -26,11 +26,10 @@ function broadcastSSE(alert: WatcherAlert) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async function boot() {
|
|
29
|
-
|
|
30
|
-
console.error(`[livetap] Redis started on port ${redis.port}`)
|
|
29
|
+
store = new StreamStore()
|
|
31
30
|
|
|
32
|
-
manager = new ConnectionManager(
|
|
33
|
-
watchers = new WatcherManager(
|
|
31
|
+
manager = new ConnectionManager(store)
|
|
32
|
+
watchers = new WatcherManager(store, broadcastSSE)
|
|
34
33
|
|
|
35
34
|
Bun.serve({
|
|
36
35
|
port: PORT,
|
|
@@ -60,7 +59,6 @@ async function boot() {
|
|
|
60
59
|
return json({
|
|
61
60
|
status: 'running',
|
|
62
61
|
port: PORT,
|
|
63
|
-
redisPort: redis.port,
|
|
64
62
|
connections: manager.list(),
|
|
65
63
|
uptime: process.uptime(),
|
|
66
64
|
})
|
|
@@ -128,7 +126,7 @@ async function boot() {
|
|
|
128
126
|
const maxEntries = parseInt(url.searchParams.get('maxEntries') ?? '50')
|
|
129
127
|
|
|
130
128
|
try {
|
|
131
|
-
const entries =
|
|
129
|
+
const entries = readBackfill(record.streamKey, backfillSeconds, maxEntries)
|
|
132
130
|
return json({ entries, totalEntries: entries.length })
|
|
133
131
|
} catch (err) {
|
|
134
132
|
return json({ error: (err as Error).message }, 500)
|
|
@@ -221,16 +219,13 @@ async function boot() {
|
|
|
221
219
|
console.error(`[livetap] daemon listening on http://127.0.0.1:${PORT}`)
|
|
222
220
|
}
|
|
223
221
|
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
return { id, fields, ts: parseInt(id.split('-')[0]) }
|
|
233
|
-
})
|
|
222
|
+
function readBackfill(streamKey: string, backfillSeconds: number, maxEntries: number): StreamEntry[] {
|
|
223
|
+
const sinceMs = Date.now() - backfillSeconds * 1000
|
|
224
|
+
return store.range(streamKey, sinceMs, maxEntries).map((e) => ({
|
|
225
|
+
id: e.id,
|
|
226
|
+
fields: e.fields,
|
|
227
|
+
ts: parseInt(e.id.split('-')[0]),
|
|
228
|
+
}))
|
|
234
229
|
}
|
|
235
230
|
|
|
236
231
|
function json(data: unknown, status = 200) {
|
|
@@ -245,7 +240,7 @@ async function shutdown() {
|
|
|
245
240
|
console.error('[livetap] shutting down...')
|
|
246
241
|
await watchers.stopAll()
|
|
247
242
|
await manager.destroyAll()
|
|
248
|
-
|
|
243
|
+
store.stop()
|
|
249
244
|
process.exit(0)
|
|
250
245
|
}
|
|
251
246
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory stream store — replaces Redis for stream buffering.
|
|
3
|
+
* Provides append, range, trim, len, del, and subscribe (EventEmitter).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events'
|
|
7
|
+
|
|
8
|
+
export interface StreamEntry {
|
|
9
|
+
id: string // "{timestamp}-{seq}"
|
|
10
|
+
fields: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class StreamStore {
|
|
14
|
+
private streams = new Map<string, StreamEntry[]>()
|
|
15
|
+
private seqCounters = new Map<string, number>()
|
|
16
|
+
private emitter = new EventEmitter()
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
this.emitter.setMaxListeners(1000)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Append an entry to a stream. Returns the generated ID.
|
|
24
|
+
* Equivalent to Redis XADD streamKey * field1 val1 field2 val2
|
|
25
|
+
*/
|
|
26
|
+
append(streamKey: string, fields: Record<string, string>): string {
|
|
27
|
+
const ts = Date.now()
|
|
28
|
+
const seq = this.seqCounters.get(streamKey) ?? 0
|
|
29
|
+
this.seqCounters.set(streamKey, seq + 1)
|
|
30
|
+
const id = `${ts}-${seq}`
|
|
31
|
+
|
|
32
|
+
const entry: StreamEntry = { id, fields }
|
|
33
|
+
|
|
34
|
+
let stream = this.streams.get(streamKey)
|
|
35
|
+
if (!stream) {
|
|
36
|
+
stream = []
|
|
37
|
+
this.streams.set(streamKey, stream)
|
|
38
|
+
}
|
|
39
|
+
stream.push(entry)
|
|
40
|
+
|
|
41
|
+
this.emitter.emit(streamKey, entry)
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read entries from a stream in a time range.
|
|
47
|
+
* Equivalent to Redis XRANGE streamKey since + COUNT count
|
|
48
|
+
*/
|
|
49
|
+
range(streamKey: string, sinceMs: number, count?: number): StreamEntry[] {
|
|
50
|
+
const stream = this.streams.get(streamKey)
|
|
51
|
+
if (!stream) return []
|
|
52
|
+
|
|
53
|
+
const sinceStr = String(sinceMs)
|
|
54
|
+
const filtered = stream.filter((e) => {
|
|
55
|
+
const entryTs = e.id.split('-')[0]
|
|
56
|
+
return entryTs >= sinceStr
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return count ? filtered.slice(0, count) : filtered
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Trim entries older than minTs.
|
|
64
|
+
* Equivalent to Redis XTRIM streamKey MINID ~ minTs
|
|
65
|
+
*/
|
|
66
|
+
trim(streamKey: string, minTs: number): void {
|
|
67
|
+
const stream = this.streams.get(streamKey)
|
|
68
|
+
if (!stream) return
|
|
69
|
+
|
|
70
|
+
const minStr = String(minTs)
|
|
71
|
+
// Find first entry that's >= minTs
|
|
72
|
+
let cutIndex = 0
|
|
73
|
+
for (let i = 0; i < stream.length; i++) {
|
|
74
|
+
const entryTs = stream[i].id.split('-')[0]
|
|
75
|
+
if (entryTs >= minStr) break
|
|
76
|
+
cutIndex = i + 1
|
|
77
|
+
}
|
|
78
|
+
if (cutIndex > 0) {
|
|
79
|
+
stream.splice(0, cutIndex)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get entry count for a stream.
|
|
85
|
+
* Equivalent to Redis XLEN streamKey
|
|
86
|
+
*/
|
|
87
|
+
len(streamKey: string): number {
|
|
88
|
+
return this.streams.get(streamKey)?.length ?? 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delete a stream entirely.
|
|
93
|
+
* Equivalent to Redis DEL streamKey
|
|
94
|
+
*/
|
|
95
|
+
del(streamKey: string): void {
|
|
96
|
+
this.streams.delete(streamKey)
|
|
97
|
+
this.seqCounters.delete(streamKey)
|
|
98
|
+
this.emitter.removeAllListeners(streamKey)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribe to new entries on a stream.
|
|
103
|
+
* Replaces Redis XREAD BLOCK — zero-latency, event-driven.
|
|
104
|
+
* Returns an unsubscribe function.
|
|
105
|
+
*/
|
|
106
|
+
subscribe(streamKey: string, callback: (entry: StreamEntry) => void): () => void {
|
|
107
|
+
this.emitter.on(streamKey, callback)
|
|
108
|
+
return () => {
|
|
109
|
+
this.emitter.off(streamKey, callback)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a stream exists.
|
|
115
|
+
*/
|
|
116
|
+
has(streamKey: string): boolean {
|
|
117
|
+
return this.streams.has(streamKey)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stop the store — clean up all data and listeners.
|
|
122
|
+
*/
|
|
123
|
+
stop(): void {
|
|
124
|
+
this.streams.clear()
|
|
125
|
+
this.seqCounters.clear()
|
|
126
|
+
this.emitter.removeAllListeners()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
import type { WatcherCondition, WatcherDefinition } from './types.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Resolve a
|
|
10
|
-
*
|
|
9
|
+
* Resolve a field path into an object.
|
|
10
|
+
* First tries the path as a literal key (handles keys like "2.8.0"),
|
|
11
|
+
* then falls back to dot-separated nested access (handles "sensors.temperature.value").
|
|
11
12
|
*/
|
|
12
13
|
export function resolveDotPath(obj: any, path: string): any {
|
|
14
|
+
if (obj == null) return undefined
|
|
15
|
+
// Try literal key first (handles OBIS codes like "2.8.0", "32.7.0")
|
|
16
|
+
if (path in obj) return obj[path]
|
|
17
|
+
// Fall back to dot-path traversal
|
|
13
18
|
return path.split('.').reduce((o, k) => o?.[k], obj)
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Watcher Manager — CRUD + evaluation loops for expression watchers.
|
|
3
|
-
* Stores definitions in
|
|
3
|
+
* Stores definitions in-memory. Evaluates via StreamStore subscriptions.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import Redis from 'ioredis'
|
|
7
6
|
import { mkdirSync, appendFileSync, statSync, writeFileSync, readFileSync, unlinkSync } from 'fs'
|
|
8
7
|
import { resolve } from 'path'
|
|
9
8
|
import { homedir } from 'os'
|
|
9
|
+
import type { StreamStore, StreamEntry } from '../stream-store.js'
|
|
10
10
|
import type { WatcherCondition, WatcherDefinition, WatcherInfo, WatcherAlert, WatcherAction } from './types.js'
|
|
11
|
+
import { resolveDotPath } from './engine.js'
|
|
11
12
|
import { VALID_OPS } from './types.js'
|
|
12
13
|
import { evaluateWatcher, extractMatchedValues, formatExpression } from './engine.js'
|
|
13
14
|
|
|
@@ -24,15 +25,13 @@ const MAX_LOG_SIZE = 512 * 1024 // 512KB
|
|
|
24
25
|
export type AlertCallback = (alert: WatcherAlert) => void
|
|
25
26
|
|
|
26
27
|
export class WatcherManager {
|
|
27
|
-
private
|
|
28
|
-
private
|
|
28
|
+
private store: StreamStore
|
|
29
|
+
private subscriptions = new Map<string, () => void>() // watcherId → unsubscribe
|
|
29
30
|
private info = new Map<string, WatcherInfo>()
|
|
30
31
|
private onAlert: AlertCallback
|
|
31
|
-
private redis: Redis
|
|
32
32
|
|
|
33
|
-
constructor(
|
|
34
|
-
this.
|
|
35
|
-
this.redisUrl = redisUrl
|
|
33
|
+
constructor(store: StreamStore, onAlert: AlertCallback) {
|
|
34
|
+
this.store = store
|
|
36
35
|
this.onAlert = onAlert
|
|
37
36
|
mkdirSync(LOG_DIR, { recursive: true })
|
|
38
37
|
}
|
|
@@ -73,9 +72,6 @@ export class WatcherManager {
|
|
|
73
72
|
updatedAt: now,
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
// Store in flat hash (globally unique watcher IDs)
|
|
77
|
-
await this.redis.hset('livetap:watchers', id, JSON.stringify(def))
|
|
78
|
-
|
|
79
75
|
const info: WatcherInfo = { ...def, matchCount: 0, entriesChecked: 0 }
|
|
80
76
|
this.info.set(id, info)
|
|
81
77
|
|
|
@@ -135,10 +131,6 @@ export class WatcherManager {
|
|
|
135
131
|
info.updatedAt = new Date().toISOString()
|
|
136
132
|
info.status = 'running'
|
|
137
133
|
|
|
138
|
-
// Persist
|
|
139
|
-
const def: WatcherDefinition = { ...info }
|
|
140
|
-
await this.redis.hset('livetap:watchers', watcherId, JSON.stringify(def))
|
|
141
|
-
|
|
142
134
|
this.writeLog(watcherId, `UPDATED conditions=${formatExpression(info.conditions, info.match)} cooldown=${info.cooldown}s`)
|
|
143
135
|
this.startLoop(watcherId, streamKey, info)
|
|
144
136
|
|
|
@@ -162,7 +154,6 @@ export class WatcherManager {
|
|
|
162
154
|
|
|
163
155
|
this.stopLoop(watcherId)
|
|
164
156
|
this.info.delete(watcherId)
|
|
165
|
-
await this.redis.hdel('livetap:watchers', watcherId)
|
|
166
157
|
|
|
167
158
|
// Delete log file
|
|
168
159
|
try { unlinkSync(resolve(LOG_DIR, `${watcherId}.log`)) } catch { /* ok */ }
|
|
@@ -178,114 +169,93 @@ export class WatcherManager {
|
|
|
178
169
|
}
|
|
179
170
|
|
|
180
171
|
private startLoop(watcherId: string, streamKey: string, def: WatcherDefinition) {
|
|
181
|
-
const abort = new AbortController()
|
|
182
|
-
const reader = new Redis(this.redisUrl)
|
|
183
|
-
this.loops.set(watcherId, { abort, reader })
|
|
184
|
-
|
|
185
172
|
const info = this.info.get(watcherId)!
|
|
186
173
|
let lastAlertTime = 0
|
|
187
174
|
let checkpointTime = Date.now()
|
|
188
|
-
const fieldNotFoundThrottle = new Map<string, number>()
|
|
175
|
+
const fieldNotFoundThrottle = new Map<string, number>()
|
|
176
|
+
|
|
177
|
+
const onEntry = (entry: StreamEntry) => {
|
|
178
|
+
try {
|
|
179
|
+
info.entriesChecked++
|
|
180
|
+
info.lastChecked = new Date().toISOString()
|
|
181
|
+
|
|
182
|
+
const fields = entry.fields
|
|
189
183
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
while (!abort.signal.aborted) {
|
|
184
|
+
// Parse payload: use parsed JSON object if available, otherwise raw fields
|
|
185
|
+
let payload: any
|
|
193
186
|
try {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
payload = JSON.parse(fields.payload ?? '{}')
|
|
213
|
-
} catch {
|
|
214
|
-
// Plain text — use raw Redis fields as the payload
|
|
215
|
-
// This allows conditions like { field: "payload", op: "contains", value: "ERROR" }
|
|
216
|
-
payload = fields
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Log missing fields (throttled: once per field per minute)
|
|
220
|
-
for (const c of def.conditions) {
|
|
221
|
-
const val = resolveDotPathImport(payload, c.field)
|
|
222
|
-
if (val === undefined) {
|
|
223
|
-
const last = fieldNotFoundThrottle.get(c.field) ?? 0
|
|
224
|
-
if (Date.now() - last > 60_000) {
|
|
225
|
-
this.writeLog(watcherId, `FIELD_NOT_FOUND ${c.field} in entry ${id}`)
|
|
226
|
-
fieldNotFoundThrottle.set(c.field, Date.now())
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Evaluate
|
|
232
|
-
const matched = evaluateWatcher(payload, def)
|
|
233
|
-
if (matched) {
|
|
234
|
-
const now = Date.now()
|
|
235
|
-
if (now - lastAlertTime >= def.cooldown * 1000) {
|
|
236
|
-
lastAlertTime = now
|
|
237
|
-
info.matchCount++
|
|
238
|
-
info.lastMatch = new Date().toISOString()
|
|
239
|
-
|
|
240
|
-
const matchedValues = extractMatchedValues(payload, def.conditions)
|
|
241
|
-
const expression = formatExpression(def.conditions, def.match)
|
|
242
|
-
|
|
243
|
-
this.writeLog(watcherId, `MATCH ${Object.entries(matchedValues).map(([k, v]) => `${k}=${v}`).join(' ')} action=${typeof def.action === 'string' ? def.action : JSON.stringify(def.action)}`)
|
|
244
|
-
|
|
245
|
-
const alert: WatcherAlert = {
|
|
246
|
-
watcherId,
|
|
247
|
-
connectionId: def.connectionId,
|
|
248
|
-
expression,
|
|
249
|
-
matchedValues,
|
|
250
|
-
entry: fields,
|
|
251
|
-
ts: now,
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Execute action
|
|
255
|
-
await this.executeAction(def.action, alert)
|
|
256
|
-
this.onAlert(alert)
|
|
257
|
-
} else {
|
|
258
|
-
const remaining = Math.round((def.cooldown * 1000 - (Date.now() - lastAlertTime)) / 1000)
|
|
259
|
-
this.writeLog(watcherId, `SUPPRESSED ${formatExpression(def.conditions, def.match)} (cooldown ${remaining}s remaining)`)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Checkpoint every 5 minutes
|
|
264
|
-
if (Date.now() - checkpointTime > 5 * 60 * 1000) {
|
|
265
|
-
this.writeLog(watcherId, `CHECKPOINT entries_checked=${info.entriesChecked} matches=${info.matchCount}`)
|
|
266
|
-
checkpointTime = Date.now()
|
|
267
|
-
}
|
|
187
|
+
const parsed = JSON.parse(fields.payload ?? '{}')
|
|
188
|
+
// Only use parsed result if it's an object (has addressable fields).
|
|
189
|
+
// Primitives (numbers, strings, booleans) fall through to raw fields
|
|
190
|
+
// so "payload" remains addressable for regex/contains/numeric conditions.
|
|
191
|
+
payload = (typeof parsed === 'object' && parsed !== null) ? parsed : fields
|
|
192
|
+
} catch {
|
|
193
|
+
// Not valid JSON — use raw fields as the payload
|
|
194
|
+
payload = fields
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Log missing fields (throttled: once per field per minute)
|
|
198
|
+
for (const c of def.conditions) {
|
|
199
|
+
const val = resolveDotPath(payload, c.field)
|
|
200
|
+
if (val === undefined) {
|
|
201
|
+
const last = fieldNotFoundThrottle.get(c.field) ?? 0
|
|
202
|
+
if (Date.now() - last > 60_000) {
|
|
203
|
+
this.writeLog(watcherId, `FIELD_NOT_FOUND ${c.field} in entry ${entry.id}`)
|
|
204
|
+
fieldNotFoundThrottle.set(c.field, Date.now())
|
|
268
205
|
}
|
|
269
206
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Evaluate
|
|
210
|
+
const matched = evaluateWatcher(payload, def)
|
|
211
|
+
if (matched) {
|
|
212
|
+
const now = Date.now()
|
|
213
|
+
if (now - lastAlertTime >= def.cooldown * 1000) {
|
|
214
|
+
lastAlertTime = now
|
|
215
|
+
info.matchCount++
|
|
216
|
+
info.lastMatch = new Date().toISOString()
|
|
217
|
+
|
|
218
|
+
const matchedValues = extractMatchedValues(payload, def.conditions)
|
|
219
|
+
const expression = formatExpression(def.conditions, def.match)
|
|
220
|
+
|
|
221
|
+
this.writeLog(watcherId, `MATCH ${Object.entries(matchedValues).map(([k, v]) => `${k}=${v}`).join(' ')} action=${typeof def.action === 'string' ? def.action : JSON.stringify(def.action)}`)
|
|
222
|
+
|
|
223
|
+
const alert: WatcherAlert = {
|
|
224
|
+
watcherId,
|
|
225
|
+
connectionId: def.connectionId,
|
|
226
|
+
expression,
|
|
227
|
+
matchedValues,
|
|
228
|
+
entry: fields,
|
|
229
|
+
ts: now,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.executeAction(def.action, alert)
|
|
233
|
+
this.onAlert(alert)
|
|
234
|
+
} else {
|
|
235
|
+
const remaining = Math.round((def.cooldown * 1000 - (Date.now() - lastAlertTime)) / 1000)
|
|
236
|
+
this.writeLog(watcherId, `SUPPRESSED ${formatExpression(def.conditions, def.match)} (cooldown ${remaining}s remaining)`)
|
|
274
237
|
}
|
|
275
238
|
}
|
|
239
|
+
|
|
240
|
+
// Checkpoint every 5 minutes
|
|
241
|
+
if (Date.now() - checkpointTime > 5 * 60 * 1000) {
|
|
242
|
+
this.writeLog(watcherId, `CHECKPOINT entries_checked=${info.entriesChecked} matches=${info.matchCount}`)
|
|
243
|
+
checkpointTime = Date.now()
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.writeLog(watcherId, `ERROR ${(err as Error).message}`)
|
|
276
247
|
}
|
|
277
|
-
reader.disconnect()
|
|
278
248
|
}
|
|
279
249
|
|
|
280
|
-
|
|
250
|
+
const unsub = this.store.subscribe(streamKey, onEntry)
|
|
251
|
+
this.subscriptions.set(watcherId, unsub)
|
|
281
252
|
}
|
|
282
253
|
|
|
283
254
|
private stopLoop(watcherId: string) {
|
|
284
|
-
const
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
this.loops.delete(watcherId)
|
|
255
|
+
const unsub = this.subscriptions.get(watcherId)
|
|
256
|
+
if (unsub) {
|
|
257
|
+
unsub()
|
|
258
|
+
this.subscriptions.delete(watcherId)
|
|
289
259
|
}
|
|
290
260
|
const info = this.info.get(watcherId)
|
|
291
261
|
if (info) info.status = 'stopped'
|
|
@@ -342,13 +312,9 @@ export class WatcherManager {
|
|
|
342
312
|
}
|
|
343
313
|
|
|
344
314
|
async stopAll() {
|
|
345
|
-
for (const id of this.
|
|
315
|
+
for (const id of this.subscriptions.keys()) {
|
|
346
316
|
this.stopLoop(id)
|
|
347
317
|
}
|
|
348
318
|
}
|
|
349
319
|
}
|
|
350
320
|
|
|
351
|
-
// Import from engine to avoid circular
|
|
352
|
-
function resolveDotPathImport(obj: any, path: string): any {
|
|
353
|
-
return path.split('.').reduce((o, k) => o?.[k], obj)
|
|
354
|
-
}
|