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
package/src/cli/status.ts
CHANGED
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
* livetap status — Show daemon, connections, and watchers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { isDaemonRunning, daemonJson } from './daemon-client.js'
|
|
5
|
+
import { isDaemonRunning, daemonJson, readPid } from './daemon-client.js'
|
|
6
6
|
|
|
7
7
|
export async function run(args: string[]) {
|
|
8
8
|
if (!(await isDaemonRunning())) {
|
|
9
|
-
|
|
9
|
+
const pid = readPid()
|
|
10
|
+
if (pid) {
|
|
11
|
+
console.log(`livetap daemon is not responding (stale PID ${pid}). Try "livetap start".`)
|
|
12
|
+
} else {
|
|
13
|
+
console.log('livetap is not running. Use "livetap start" or "livetap setup" to begin.')
|
|
14
|
+
}
|
|
10
15
|
return
|
|
11
16
|
}
|
|
12
17
|
|
|
@@ -19,8 +24,7 @@ export async function run(args: string[]) {
|
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const uptime = formatUptime(data.uptime)
|
|
22
|
-
console.log(`livetap daemon running on :${data.port} (uptime ${uptime})`)
|
|
23
|
-
console.log(`Redis: localhost:${data.redisPort}\n`)
|
|
27
|
+
console.log(`livetap daemon running on :${data.port} (uptime ${uptime})\n`)
|
|
24
28
|
|
|
25
29
|
const conns = data.connections || []
|
|
26
30
|
if (conns.length === 0) {
|
|
@@ -34,6 +38,20 @@ export async function run(args: string[]) {
|
|
|
34
38
|
}
|
|
35
39
|
console.log()
|
|
36
40
|
}
|
|
41
|
+
|
|
42
|
+
// Fetch watcher count
|
|
43
|
+
try {
|
|
44
|
+
const watchers = await daemonJson('/watchers')
|
|
45
|
+
if (watchers.length > 0) {
|
|
46
|
+
const running = watchers.filter((w: any) => w.status === 'running').length
|
|
47
|
+
console.log(`Watchers (${watchers.length}, ${running} running)`)
|
|
48
|
+
for (const w of watchers) {
|
|
49
|
+
const expr = w.conditions?.map((c: any) => `${c.field} ${c.op} ${c.value}`).join(w.match === 'all' ? ' AND ' : ' OR ') ?? ''
|
|
50
|
+
console.log(` ${w.id} ${w.status.padEnd(8)} ${expr.slice(0, 50)}`)
|
|
51
|
+
}
|
|
52
|
+
console.log()
|
|
53
|
+
}
|
|
54
|
+
} catch { /* no watchers endpoint or error */ }
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
function formatUptime(seconds: number): string {
|
package/src/cli/stop.ts
CHANGED
|
@@ -2,45 +2,32 @@
|
|
|
2
2
|
* livetap stop — Stop the daemon.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { homedir } from 'os'
|
|
7
|
-
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
|
8
|
-
import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
|
|
9
|
-
|
|
10
|
-
const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
|
|
5
|
+
import { isDaemonRunning, readPid, isPidAlive, cleanPidFile, getDaemonPort } from './daemon-client.js'
|
|
11
6
|
|
|
12
7
|
export async function run(_args: string[]) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
try { process.kill(state.pid, 'SIGTERM') } catch { /* already dead */ }
|
|
19
|
-
unlinkSync(STATE_PATH)
|
|
20
|
-
console.log('livetap daemon stopped (cleaned up stale state)')
|
|
21
|
-
} catch {
|
|
22
|
-
unlinkSync(STATE_PATH)
|
|
23
|
-
}
|
|
24
|
-
} else {
|
|
25
|
-
console.log('livetap is not running.')
|
|
26
|
-
}
|
|
8
|
+
const pid = readPid()
|
|
9
|
+
const running = await isDaemonRunning()
|
|
10
|
+
|
|
11
|
+
if (!running && !pid) {
|
|
12
|
+
console.log('livetap is not running.')
|
|
27
13
|
return
|
|
28
14
|
}
|
|
29
15
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
16
|
+
if (!running && pid) {
|
|
17
|
+
// PID file exists but daemon isn't responding
|
|
18
|
+
if (isPidAlive(pid)) {
|
|
19
|
+
// Process exists but not responding on port — might be something else
|
|
20
|
+
console.log(`Warning: PID ${pid} exists but daemon is not responding on :${getDaemonPort()}`)
|
|
21
|
+
try { process.kill(pid, 'SIGTERM') } catch { /* ok */ }
|
|
22
|
+
}
|
|
23
|
+
cleanPidFile()
|
|
24
|
+
console.log('livetap daemon stopped (cleaned up stale state)')
|
|
25
|
+
return
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
//
|
|
28
|
+
// Daemon is running — send SIGTERM via PID or wait for port to close
|
|
40
29
|
if (pid) {
|
|
41
|
-
try {
|
|
42
|
-
process.kill(pid, 'SIGTERM')
|
|
43
|
-
} catch { /* already gone */ }
|
|
30
|
+
try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
|
|
44
31
|
}
|
|
45
32
|
|
|
46
33
|
// Wait for shutdown
|
|
@@ -55,10 +42,6 @@ export async function run(_args: string[]) {
|
|
|
55
42
|
if (pid) try { process.kill(pid, 'SIGKILL') } catch { /* ok */ }
|
|
56
43
|
}
|
|
57
44
|
|
|
58
|
-
|
|
59
|
-
if (existsSync(STATE_PATH)) {
|
|
60
|
-
try { unlinkSync(STATE_PATH) } catch { /* ok */ }
|
|
61
|
-
}
|
|
62
|
-
|
|
45
|
+
cleanPidFile()
|
|
63
46
|
console.log('livetap daemon stopped')
|
|
64
47
|
}
|
package/src/mcp/channel.ts
CHANGED
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
12
12
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
13
|
+
import { readFileSync } from 'fs'
|
|
13
14
|
import { registerTools } from './tools.js'
|
|
14
15
|
import { generateInstructions } from '../shared/catalog-generators.js'
|
|
15
16
|
|
|
17
|
+
const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf-8'))
|
|
18
|
+
|
|
16
19
|
const DAEMON_PORT = parseInt(process.env.LIVETAP_PORT || '8788')
|
|
17
20
|
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`
|
|
18
21
|
|
|
19
22
|
const INSTRUCTIONS = generateInstructions()
|
|
20
23
|
|
|
21
|
-
async function waitForDaemon(maxWaitMs =
|
|
24
|
+
async function waitForDaemon(maxWaitMs = 30_000): Promise<boolean> {
|
|
22
25
|
const deadline = Date.now() + maxWaitMs
|
|
23
26
|
while (Date.now() < deadline) {
|
|
24
27
|
try {
|
|
@@ -37,13 +40,15 @@ async function autoStartDaemon(): Promise<boolean> {
|
|
|
37
40
|
if (res.ok) return true
|
|
38
41
|
} catch { /* not running */ }
|
|
39
42
|
|
|
40
|
-
// Auto-start
|
|
43
|
+
// Auto-start via CLI (single code path — resolves paths correctly from any CWD)
|
|
44
|
+
const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
|
|
41
45
|
console.error('[livetap-mcp] Daemon not running, auto-starting...')
|
|
42
|
-
Bun.spawn(['bun', '
|
|
46
|
+
const proc = Bun.spawn(['bun', startScript, 'start'], {
|
|
43
47
|
env: { ...process.env, LIVETAP_PORT: String(DAEMON_PORT) },
|
|
44
48
|
stdout: 'ignore',
|
|
45
49
|
stderr: 'ignore',
|
|
46
50
|
})
|
|
51
|
+
proc.unref()
|
|
47
52
|
|
|
48
53
|
return waitForDaemon()
|
|
49
54
|
}
|
|
@@ -51,7 +56,11 @@ async function autoStartDaemon(): Promise<boolean> {
|
|
|
51
56
|
async function connectToSSE(mcp: Server) {
|
|
52
57
|
try {
|
|
53
58
|
const res = await fetch(`${DAEMON_URL}/events`)
|
|
54
|
-
if (!res.ok || !res.body)
|
|
59
|
+
if (!res.ok || !res.body) {
|
|
60
|
+
// Daemon not ready — retry
|
|
61
|
+
setTimeout(() => connectToSSE(mcp), 5000)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
55
64
|
|
|
56
65
|
const reader = res.body.getReader()
|
|
57
66
|
const decoder = new TextDecoder()
|
|
@@ -85,9 +94,10 @@ async function connectToSSE(mcp: Server) {
|
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
}
|
|
97
|
+
// Clean disconnect (daemon died or restarted) — retry
|
|
98
|
+
setTimeout(() => connectToSSE(mcp), 5000)
|
|
88
99
|
} catch {
|
|
89
|
-
// SSE connection
|
|
90
|
-
// Silently retry after delay
|
|
100
|
+
// SSE connection error — retry after delay
|
|
91
101
|
setTimeout(() => connectToSSE(mcp), 5000)
|
|
92
102
|
}
|
|
93
103
|
}
|
|
@@ -95,11 +105,11 @@ async function connectToSSE(mcp: Server) {
|
|
|
95
105
|
async function main() {
|
|
96
106
|
const daemonReady = await autoStartDaemon()
|
|
97
107
|
if (!daemonReady) {
|
|
98
|
-
console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools
|
|
108
|
+
console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools will auto-retry on first call.')
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
const mcp = new Server(
|
|
102
|
-
{ name: 'LiveTap', version:
|
|
112
|
+
{ name: 'LiveTap', version: PKG.version },
|
|
103
113
|
{
|
|
104
114
|
capabilities: {
|
|
105
115
|
experimental: { 'claude/channel': {} },
|
|
@@ -118,4 +128,12 @@ async function main() {
|
|
|
118
128
|
connectToSSE(mcp)
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
// Prevent unhandled errors from crashing the proxy process
|
|
132
|
+
process.on('unhandledRejection', (err) => {
|
|
133
|
+
console.error('[livetap-mcp] unhandled rejection:', err)
|
|
134
|
+
})
|
|
135
|
+
process.on('uncaughtException', (err) => {
|
|
136
|
+
console.error('[livetap-mcp] uncaught exception:', err)
|
|
137
|
+
})
|
|
138
|
+
|
|
121
139
|
main()
|
package/src/mcp/tools.ts
CHANGED
|
@@ -3,173 +3,14 @@
|
|
|
3
3
|
* Registers tools on an MCP Server that proxy to the daemon HTTP API.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { z } from 'zod'
|
|
7
6
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
8
7
|
import {
|
|
9
8
|
ListToolsRequestSchema,
|
|
10
9
|
CallToolRequestSchema,
|
|
11
10
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
12
11
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
name: 'create_connection',
|
|
16
|
-
description: 'Create a data connection. MQTT: connects to a broker and subscribes to topics. WebSocket: connects to a remote WS URL. Webhook: creates an HTTP ingest endpoint.',
|
|
17
|
-
inputSchema: {
|
|
18
|
-
type: 'object' as const,
|
|
19
|
-
properties: {
|
|
20
|
-
type: { type: 'string', enum: ['mqtt', 'webhook', 'websocket', 'file'], description: 'Source type', default: 'mqtt' },
|
|
21
|
-
name: { type: 'string', description: 'Display name for the connection' },
|
|
22
|
-
broker: { type: 'string', description: 'MQTT broker hostname (required for mqtt)' },
|
|
23
|
-
port: { type: 'number', description: 'Broker port (default 1883 for mqtt)', default: 1883 },
|
|
24
|
-
tls: { type: 'boolean', description: 'Use TLS (default false)', default: false },
|
|
25
|
-
username: { type: 'string', description: 'MQTT username', default: '' },
|
|
26
|
-
password: { type: 'string', description: 'MQTT password', default: '' },
|
|
27
|
-
topics: { type: 'array', items: { type: 'string' }, description: 'MQTT topic filters (required for mqtt)' },
|
|
28
|
-
url: { type: 'string', description: 'WebSocket URL (required for websocket)' },
|
|
29
|
-
headers: { type: 'object', description: 'WebSocket auth headers' },
|
|
30
|
-
handshake: { type: 'string', description: 'Message to send after WS connect (e.g. subscription JSON)' },
|
|
31
|
-
path: { type: 'string', description: 'Absolute file path to tail (required for file type, e.g. "/var/log/app.log")' },
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: 'list_connections',
|
|
37
|
-
description: 'List all active connections with their status, message rate, and buffered count.',
|
|
38
|
-
inputSchema: { type: 'object' as const, properties: {} },
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: 'get_connection',
|
|
42
|
-
description: 'Get detailed status of a specific connection.',
|
|
43
|
-
inputSchema: {
|
|
44
|
-
type: 'object' as const,
|
|
45
|
-
properties: {
|
|
46
|
-
connectionId: { type: 'string', description: 'The connection ID (e.g. "conn_a1b2c3d4")' },
|
|
47
|
-
},
|
|
48
|
-
required: ['connectionId'],
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: 'destroy_connection',
|
|
53
|
-
description: 'Destroy a connection — stops the source subscriber, cleans up the Redis stream.',
|
|
54
|
-
inputSchema: {
|
|
55
|
-
type: 'object' as const,
|
|
56
|
-
properties: {
|
|
57
|
-
connectionId: { type: 'string', description: 'The connection ID to destroy' },
|
|
58
|
-
},
|
|
59
|
-
required: ['connectionId'],
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: 'read_stream',
|
|
64
|
-
description: "Read recent entries from a connection's live stream. Use this to inspect what data is flowing through a connection and understand the payload structure before creating watchers.",
|
|
65
|
-
inputSchema: {
|
|
66
|
-
type: 'object' as const,
|
|
67
|
-
properties: {
|
|
68
|
-
connectionId: { type: 'string', description: 'The connection ID to read from' },
|
|
69
|
-
backfillSeconds: { type: 'number', description: 'Include entries from the last N seconds (default 60)', default: 60 },
|
|
70
|
-
maxEntries: { type: 'number', description: 'Max entries to return (default 10)', default: 10 },
|
|
71
|
-
},
|
|
72
|
-
required: ['connectionId'],
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
name: 'create_watcher',
|
|
77
|
-
description: 'Create an expression-based watcher on a connection. The watcher evaluates structured conditions against each stream entry and fires an alert when conditions match. ALWAYS use read_stream first to understand the data shape and field paths.',
|
|
78
|
-
inputSchema: {
|
|
79
|
-
type: 'object' as const,
|
|
80
|
-
properties: {
|
|
81
|
-
connectionId: { type: 'string', description: 'The connection ID to watch' },
|
|
82
|
-
conditions: {
|
|
83
|
-
type: 'array',
|
|
84
|
-
description: 'Array of conditions: [{field: "dot.path", op: ">", value: 50}]. Supported ops: >, <, >=, <=, ==, !=, contains, matches (regex)',
|
|
85
|
-
items: {
|
|
86
|
-
type: 'object',
|
|
87
|
-
properties: {
|
|
88
|
-
field: { type: 'string', description: 'Dot-path into the JSON payload (e.g. "sensors.temperature.value")' },
|
|
89
|
-
op: { type: 'string', enum: ['>', '<', '>=', '<=', '==', '!=', 'contains', 'matches'], description: 'Comparison operator. "matches" takes a regex pattern string.' },
|
|
90
|
-
value: { description: 'Value to compare against (number, string, or boolean)' },
|
|
91
|
-
},
|
|
92
|
-
required: ['field', 'op', 'value'],
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
match: { type: 'string', enum: ['all', 'any'], description: 'How to combine conditions: "all" (AND) or "any" (OR). Default: "all"', default: 'all' },
|
|
96
|
-
action: { description: '"channel_alert" (default), or {webhook: "url"}, or {shell: "command"}', default: 'channel_alert' },
|
|
97
|
-
cooldown: { type: 'number', description: 'Seconds between repeated alerts. 0 for every match, 60 default. Use 0 for rare events (webhooks), 30-60 for sensors, 300+ for high-frequency streams.', default: 60 },
|
|
98
|
-
},
|
|
99
|
-
required: ['connectionId', 'conditions'],
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
name: 'list_watchers',
|
|
104
|
-
description: 'List watchers. Optionally filter by connectionId. If omitted, lists all watchers.',
|
|
105
|
-
inputSchema: {
|
|
106
|
-
type: 'object' as const,
|
|
107
|
-
properties: {
|
|
108
|
-
connectionId: { type: 'string', description: 'Optional: filter by connection ID' },
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
name: 'get_watcher',
|
|
114
|
-
description: 'Get details of a specific watcher including conditions, status, match count, and config.',
|
|
115
|
-
inputSchema: {
|
|
116
|
-
type: 'object' as const,
|
|
117
|
-
properties: {
|
|
118
|
-
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
119
|
-
},
|
|
120
|
-
required: ['watcherId'],
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
name: 'get_watcher_logs',
|
|
125
|
-
description: 'Get evaluation logs from a watcher — shows MATCH, SUPPRESSED, FIELD_NOT_FOUND, and CHECKPOINT events.',
|
|
126
|
-
inputSchema: {
|
|
127
|
-
type: 'object' as const,
|
|
128
|
-
properties: {
|
|
129
|
-
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
130
|
-
lines: { type: 'number', description: 'Number of log lines to return (default 50)', default: 50 },
|
|
131
|
-
},
|
|
132
|
-
required: ['watcherId'],
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
name: 'update_watcher',
|
|
137
|
-
description: "Update a watcher's conditions, match mode, action, or cooldown. The watcher restarts with the new config.",
|
|
138
|
-
inputSchema: {
|
|
139
|
-
type: 'object' as const,
|
|
140
|
-
properties: {
|
|
141
|
-
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
142
|
-
conditions: { type: 'array', description: 'New conditions array', items: { type: 'object' } },
|
|
143
|
-
match: { type: 'string', enum: ['all', 'any'] },
|
|
144
|
-
action: { description: 'New action' },
|
|
145
|
-
cooldown: { type: 'number', description: 'New cooldown in seconds' },
|
|
146
|
-
},
|
|
147
|
-
required: ['watcherId'],
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
{
|
|
151
|
-
name: 'delete_watcher',
|
|
152
|
-
description: 'Stop and remove a watcher.',
|
|
153
|
-
inputSchema: {
|
|
154
|
-
type: 'object' as const,
|
|
155
|
-
properties: {
|
|
156
|
-
watcherId: { type: 'string', description: 'The watcher ID to delete' },
|
|
157
|
-
},
|
|
158
|
-
required: ['watcherId'],
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
name: 'restart_watcher',
|
|
163
|
-
description: 'Restart a stopped watcher.',
|
|
164
|
-
inputSchema: {
|
|
165
|
-
type: 'object' as const,
|
|
166
|
-
properties: {
|
|
167
|
-
watcherId: { type: 'string', description: 'The watcher ID to restart' },
|
|
168
|
-
},
|
|
169
|
-
required: ['watcherId'],
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
]
|
|
12
|
+
export { TOOLS } from '../shared/canonical/tools.js'
|
|
13
|
+
import { TOOLS } from '../shared/canonical/tools.js'
|
|
173
14
|
|
|
174
15
|
function text(content: string) {
|
|
175
16
|
return { content: [{ type: 'text' as const, text: content }] }
|
|
@@ -179,9 +20,35 @@ function error(content: string) {
|
|
|
179
20
|
return { content: [{ type: 'text' as const, text: content }], isError: true }
|
|
180
21
|
}
|
|
181
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Try to start the daemon via CLI. Returns true if daemon becomes healthy.
|
|
25
|
+
*/
|
|
26
|
+
async function tryStartDaemon(daemonUrl: string): Promise<boolean> {
|
|
27
|
+
const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
|
|
28
|
+
const port = new URL(daemonUrl).port || '8788'
|
|
29
|
+
const proc = Bun.spawn(['bun', startScript, 'start'], {
|
|
30
|
+
env: { ...process.env, LIVETAP_PORT: port },
|
|
31
|
+
stdout: 'ignore',
|
|
32
|
+
stderr: 'ignore',
|
|
33
|
+
})
|
|
34
|
+
proc.unref()
|
|
35
|
+
|
|
36
|
+
// Wait up to 15s for daemon to be ready
|
|
37
|
+
const deadline = Date.now() + 15_000
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${daemonUrl}/status`)
|
|
41
|
+
if (res.ok) return true
|
|
42
|
+
} catch { /* not ready */ }
|
|
43
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
182
48
|
/**
|
|
183
49
|
* Register all livetap MCP tools on the given server.
|
|
184
50
|
* Tools proxy to the daemon HTTP API at the given base URL.
|
|
51
|
+
* Includes auto-restart + retry on connection failure.
|
|
185
52
|
*/
|
|
186
53
|
export function registerTools(server: Server, daemonUrl: string) {
|
|
187
54
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -304,11 +171,40 @@ export function registerTools(server: Server, daemonUrl: string) {
|
|
|
304
171
|
return text(await res.text())
|
|
305
172
|
}
|
|
306
173
|
|
|
174
|
+
case 'status': {
|
|
175
|
+
const res = await fetch(`${daemonUrl}/status`)
|
|
176
|
+
if (!res.ok) return error('Daemon not reachable')
|
|
177
|
+
const status = await res.json()
|
|
178
|
+
// Also fetch watcher count
|
|
179
|
+
let watcherCount = 0
|
|
180
|
+
try {
|
|
181
|
+
const wr = await fetch(`${daemonUrl}/watchers`)
|
|
182
|
+
if (wr.ok) {
|
|
183
|
+
const watchers = await wr.json()
|
|
184
|
+
watcherCount = watchers.length
|
|
185
|
+
}
|
|
186
|
+
} catch { /* ok */ }
|
|
187
|
+
return text(JSON.stringify({ ...status, watcherCount }, null, 2))
|
|
188
|
+
}
|
|
189
|
+
|
|
307
190
|
default:
|
|
308
191
|
return error(`Unknown tool: ${name}`)
|
|
309
192
|
}
|
|
310
193
|
} catch (err) {
|
|
311
|
-
|
|
194
|
+
// Connection failed — try to auto-start daemon and retry
|
|
195
|
+
const msg = (err as Error).message
|
|
196
|
+
if (msg.includes('Unable to connect') || msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
|
|
197
|
+
const started = await tryStartDaemon(daemonUrl)
|
|
198
|
+
if (!started) {
|
|
199
|
+
return error('livetap daemon could not be started. Run "livetap start" manually.')
|
|
200
|
+
}
|
|
201
|
+
// Retry: simple fetch to /status to confirm, then tell agent to retry
|
|
202
|
+
return text(JSON.stringify({
|
|
203
|
+
note: 'Daemon was restarted. Please retry your request.',
|
|
204
|
+
status: 'daemon_restarted',
|
|
205
|
+
}))
|
|
206
|
+
}
|
|
207
|
+
return error(`livetap daemon error: ${msg}`)
|
|
312
208
|
}
|
|
313
209
|
})
|
|
314
210
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Connection Manager — creates, tracks, and destroys data source connections.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import type { StreamStore } from './stream-store.js'
|
|
6
6
|
import type { ConnectionConfig, ConnectionRecord, ConnectionStatus, Subscriber } from './types.js'
|
|
7
7
|
import { MqttSubscriber } from './connections/mqtt.js'
|
|
8
8
|
import { WebhookIngestor } from './connections/webhook.js'
|
|
@@ -18,12 +18,10 @@ function generateId(): string {
|
|
|
18
18
|
|
|
19
19
|
export class ConnectionManager {
|
|
20
20
|
private connections = new Map<string, ConnectionRecord>()
|
|
21
|
-
private
|
|
22
|
-
private redisUrl: string
|
|
21
|
+
private store: StreamStore
|
|
23
22
|
|
|
24
|
-
constructor(
|
|
25
|
-
this.
|
|
26
|
-
this.redisUrl = redisUrl
|
|
23
|
+
constructor(store: StreamStore) {
|
|
24
|
+
this.store = store
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
async create(config: ConnectionConfig, name?: string): Promise<ConnectionRecord> {
|
|
@@ -51,27 +49,27 @@ export class ConnectionManager {
|
|
|
51
49
|
record.subscriber = new MqttSubscriber({
|
|
52
50
|
config,
|
|
53
51
|
streamKey,
|
|
54
|
-
|
|
52
|
+
store: this.store,
|
|
55
53
|
onMessage,
|
|
56
54
|
})
|
|
57
55
|
} else if (config.type === 'webhook') {
|
|
58
56
|
record.subscriber = new WebhookIngestor({
|
|
59
57
|
streamKey,
|
|
60
|
-
|
|
58
|
+
store: this.store,
|
|
61
59
|
onMessage,
|
|
62
60
|
})
|
|
63
61
|
} else if (config.type === 'websocket') {
|
|
64
62
|
record.subscriber = new WsSubscriber({
|
|
65
63
|
config,
|
|
66
64
|
streamKey,
|
|
67
|
-
|
|
65
|
+
store: this.store,
|
|
68
66
|
onMessage,
|
|
69
67
|
})
|
|
70
68
|
} else if (config.type === 'file') {
|
|
71
69
|
record.subscriber = new FileSubscriber({
|
|
72
70
|
config,
|
|
73
71
|
streamKey,
|
|
74
|
-
|
|
72
|
+
store: this.store,
|
|
75
73
|
onMessage,
|
|
76
74
|
})
|
|
77
75
|
} else {
|
|
@@ -88,10 +86,8 @@ export class ConnectionManager {
|
|
|
88
86
|
}, 5000)
|
|
89
87
|
|
|
90
88
|
// Buffered count tracking (every 5s)
|
|
91
|
-
record.bufferedInterval = setInterval(
|
|
92
|
-
|
|
93
|
-
record.bufferedCount = await this.redis.xlen(streamKey)
|
|
94
|
-
} catch { /* ignore */ }
|
|
89
|
+
record.bufferedInterval = setInterval(() => {
|
|
90
|
+
record.bufferedCount = this.store.len(streamKey)
|
|
95
91
|
}, 5000)
|
|
96
92
|
|
|
97
93
|
this.connections.set(id, record)
|
|
@@ -119,7 +115,7 @@ export class ConnectionManager {
|
|
|
119
115
|
if (record.bufferedInterval) clearInterval(record.bufferedInterval)
|
|
120
116
|
|
|
121
117
|
await record.subscriber.stop()
|
|
122
|
-
|
|
118
|
+
this.store.del(record.streamKey)
|
|
123
119
|
this.connections.delete(id)
|
|
124
120
|
return true
|
|
125
121
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File Tailing Subscriber — watches a file for new lines (tail -f behavior)
|
|
3
|
-
* and writes each line to a
|
|
3
|
+
* and writes each line to a stream with rolling retention.
|
|
4
4
|
* Auto-detects JSON vs plain text per line.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { watch, type FSWatcher } from 'fs'
|
|
8
8
|
import { open, stat, type FileHandle } from 'fs/promises'
|
|
9
|
-
import type
|
|
9
|
+
import type { StreamStore } from '../stream-store.js'
|
|
10
10
|
import type { FileConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
|
|
11
11
|
|
|
12
12
|
export interface FileSubscriberOpts {
|
|
13
13
|
config: FileConnectionConfig
|
|
14
14
|
streamKey: string
|
|
15
|
-
|
|
15
|
+
store: StreamStore
|
|
16
16
|
retentionMs?: number
|
|
17
17
|
onMessage?: () => void
|
|
18
18
|
}
|
|
@@ -20,7 +20,7 @@ export interface FileSubscriberOpts {
|
|
|
20
20
|
export class FileSubscriber implements Subscriber {
|
|
21
21
|
private config: FileConnectionConfig
|
|
22
22
|
private streamKey: string
|
|
23
|
-
private
|
|
23
|
+
private store: StreamStore
|
|
24
24
|
private retentionMs: number
|
|
25
25
|
private onMessage: (() => void) | undefined
|
|
26
26
|
private state: ConnectionStatus['runtimeState'] = 'disconnected'
|
|
@@ -35,7 +35,7 @@ export class FileSubscriber implements Subscriber {
|
|
|
35
35
|
constructor(opts: FileSubscriberOpts) {
|
|
36
36
|
this.config = opts.config
|
|
37
37
|
this.streamKey = opts.streamKey
|
|
38
|
-
this.
|
|
38
|
+
this.store = opts.store
|
|
39
39
|
this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
|
|
40
40
|
this.onMessage = opts.onMessage
|
|
41
41
|
}
|
|
@@ -98,9 +98,8 @@ export class FileSubscriber implements Subscriber {
|
|
|
98
98
|
fields = { payload: line, format: 'text' }
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
|
|
101
|
+
this.store.append(this.streamKey, fields)
|
|
102
|
+
this.store.trim(this.streamKey, Date.now() - this.retentionMs)
|
|
104
103
|
this.onMessage?.()
|
|
105
104
|
}
|
|
106
105
|
} catch (err) {
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MQTT Subscriber — connects to an MQTT broker and writes messages
|
|
3
|
-
* to a
|
|
3
|
+
* to a stream with rolling retention.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import mqtt from 'mqtt'
|
|
7
|
-
import type
|
|
7
|
+
import type { StreamStore } from '../stream-store.js'
|
|
8
8
|
import type { MqttConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
|
|
9
9
|
|
|
10
10
|
export interface MqttSubscriberOpts {
|
|
11
11
|
config: MqttConnectionConfig
|
|
12
12
|
streamKey: string
|
|
13
|
-
|
|
13
|
+
store: StreamStore
|
|
14
14
|
retentionMs?: number
|
|
15
15
|
onMessage?: () => void
|
|
16
16
|
}
|
|
@@ -19,7 +19,7 @@ export class MqttSubscriber implements Subscriber {
|
|
|
19
19
|
private client: mqtt.MqttClient | null = null
|
|
20
20
|
private config: MqttConnectionConfig
|
|
21
21
|
private streamKey: string
|
|
22
|
-
private
|
|
22
|
+
private store: StreamStore
|
|
23
23
|
private retentionMs: number
|
|
24
24
|
private onMessage: (() => void) | undefined
|
|
25
25
|
private state: ConnectionStatus['runtimeState'] = 'disconnected'
|
|
@@ -28,7 +28,7 @@ export class MqttSubscriber implements Subscriber {
|
|
|
28
28
|
constructor(opts: MqttSubscriberOpts) {
|
|
29
29
|
this.config = opts.config
|
|
30
30
|
this.streamKey = opts.streamKey
|
|
31
|
-
this.
|
|
31
|
+
this.store = opts.store
|
|
32
32
|
this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
|
|
33
33
|
this.onMessage = opts.onMessage
|
|
34
34
|
}
|
|
@@ -77,14 +77,13 @@ export class MqttSubscriber implements Subscriber {
|
|
|
77
77
|
this.state = 'reconnecting'
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
this.client!.on('message',
|
|
80
|
+
this.client!.on('message', (topic, payload) => {
|
|
81
81
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
|
|
82
|
+
this.store.append(this.streamKey, { topic, payload: payload.toString() })
|
|
83
|
+
this.store.trim(this.streamKey, Date.now() - this.retentionMs)
|
|
85
84
|
this.onMessage?.()
|
|
86
85
|
} catch {
|
|
87
|
-
//
|
|
86
|
+
// Write failure — log but don't crash
|
|
88
87
|
}
|
|
89
88
|
})
|
|
90
89
|
})
|