agentgate 0.1.9 ā 0.3.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.
- package/README.md +22 -2
- package/package.json +1 -1
- package/src/index.js +64 -2
- package/src/lib/agentNotifier.js +150 -0
- package/src/lib/db.js +185 -8
- package/src/lib/notifier.js +45 -17
- package/src/routes/agents.js +185 -0
- package/src/routes/ui.js +663 -14
package/README.md
CHANGED
|
@@ -135,9 +135,29 @@ agentgate can notify your agent when queue items are completed, failed, or rejec
|
|
|
135
135
|
|
|
136
136
|
Compatible with OpenClaw's `/hooks/wake` endpoint. See [OpenClaw webhook docs](https://docs.openclaw.ai/automation/webhook).
|
|
137
137
|
|
|
138
|
-
##
|
|
138
|
+
## Inter-Agent Messaging
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Agents can communicate with each other through agentgate, with optional human oversight.
|
|
141
|
+
|
|
142
|
+
š **[See full documentation](docs/inter-agent-messaging.md)**
|
|
143
|
+
|
|
144
|
+
**Quick overview:**
|
|
145
|
+
- Three modes: **Off**, **Supervised** (human approval), **Open** (immediate delivery)
|
|
146
|
+
- Configure agent webhooks in Admin UI under **API Keys > Configure**
|
|
147
|
+
- Endpoints: `/api/agents/messageable`, `/api/agents/message`, `/api/agents/messages`, `/api/agents/status`
|
|
148
|
+
|
|
149
|
+
## Agent Registry
|
|
150
|
+
|
|
151
|
+
Manage your agents in the admin UI at `/ui/keys`. Each agent has:
|
|
152
|
+
|
|
153
|
+
- **Name** - Unique identifier (case-insensitive)
|
|
154
|
+
- **API Key** - Bearer token for agent ā agentgate authentication (shown once at creation)
|
|
155
|
+
- **Webhook URL** (optional) - Endpoint for agentgate ā agent notifications
|
|
156
|
+
- **Webhook Token** (optional) - Bearer token for webhook authentication
|
|
157
|
+
|
|
158
|
+
When an agent's webhook is configured, agentgate will POST notifications for:
|
|
159
|
+
- Queue item status changes (approved/rejected/completed/failed)
|
|
160
|
+
- Inter-agent message delivery or rejection
|
|
141
161
|
|
|
142
162
|
|
|
143
163
|
## Usage
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import express from 'express';
|
|
|
2
2
|
import cookieParser from 'cookie-parser';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
|
-
import { validateApiKey, getAccountsByService, getCookieSecret, getSetting } from './lib/db.js';
|
|
5
|
+
import { validateApiKey, getAccountsByService, getCookieSecret, getSetting, getMessagingMode } from './lib/db.js';
|
|
6
6
|
import { connectHsync } from './lib/hsyncManager.js';
|
|
7
7
|
import githubRoutes, { serviceInfo as githubInfo } from './routes/github.js';
|
|
8
8
|
import blueskyRoutes, { serviceInfo as blueskyInfo } from './routes/bluesky.js';
|
|
@@ -14,6 +14,7 @@ import youtubeRoutes, { serviceInfo as youtubeInfo } from './routes/youtube.js';
|
|
|
14
14
|
import jiraRoutes, { serviceInfo as jiraInfo } from './routes/jira.js';
|
|
15
15
|
import fitbitRoutes, { serviceInfo as fitbitInfo } from './routes/fitbit.js';
|
|
16
16
|
import queueRoutes from './routes/queue.js';
|
|
17
|
+
import agentsRoutes from './routes/agents.js';
|
|
17
18
|
import uiRoutes from './routes/ui.js';
|
|
18
19
|
|
|
19
20
|
// Aggregate service metadata from all routes
|
|
@@ -80,6 +81,13 @@ app.use('/api/fitbit', apiKeyAuth, readOnlyEnforce, fitbitRoutes);
|
|
|
80
81
|
// Pattern: /api/queue/{service}/{accountName}/submit
|
|
81
82
|
app.use('/api/queue', apiKeyAuth, queueRoutes);
|
|
82
83
|
|
|
84
|
+
// Agent messaging routes - require auth, allow POST for sending messages
|
|
85
|
+
// Includes apiKeyName in req for sender identification
|
|
86
|
+
app.use('/api/agents', apiKeyAuth, (req, res, next) => {
|
|
87
|
+
req.apiKeyName = req.apiKeyInfo.name;
|
|
88
|
+
next();
|
|
89
|
+
}, agentsRoutes);
|
|
90
|
+
|
|
83
91
|
// UI routes - no API key needed (local admin access)
|
|
84
92
|
app.use('/ui', uiRoutes);
|
|
85
93
|
|
|
@@ -233,7 +241,61 @@ app.get('/api/readme', apiKeyAuth, (req, res) => {
|
|
|
233
241
|
queryParams: {
|
|
234
242
|
base_url: 'Override the base URL in the generated skill (optional)'
|
|
235
243
|
}
|
|
236
|
-
}
|
|
244
|
+
},
|
|
245
|
+
agentMessaging: (() => {
|
|
246
|
+
const mode = getMessagingMode();
|
|
247
|
+
return {
|
|
248
|
+
enabled: mode !== 'off',
|
|
249
|
+
mode,
|
|
250
|
+
description: mode === 'off'
|
|
251
|
+
? 'Agent-to-agent messaging is disabled. Admin can enable it in the agentgate UI.'
|
|
252
|
+
: mode === 'supervised'
|
|
253
|
+
? 'Agents can message each other. Messages require human approval before delivery.'
|
|
254
|
+
: 'Agents can message each other freely without approval.',
|
|
255
|
+
endpoints: {
|
|
256
|
+
sendMessage: {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
path: '/api/agents/message',
|
|
259
|
+
body: { to: 'recipient_agent_name', message: 'Your message content' },
|
|
260
|
+
response: mode === 'supervised'
|
|
261
|
+
? '{ id, status: "pending", message: "Message queued for human approval" }'
|
|
262
|
+
: '{ id, status: "delivered", message: "Message delivered" }'
|
|
263
|
+
},
|
|
264
|
+
getMessages: {
|
|
265
|
+
method: 'GET',
|
|
266
|
+
path: '/api/agents/messages',
|
|
267
|
+
queryParams: { unread: 'true (optional) - only return unread messages' },
|
|
268
|
+
response: '{ mode, messages: [{ id, from, message, created_at, read }, ...] }'
|
|
269
|
+
},
|
|
270
|
+
markRead: {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
path: '/api/agents/messages/:id/read',
|
|
273
|
+
response: '{ success: true }'
|
|
274
|
+
},
|
|
275
|
+
status: {
|
|
276
|
+
method: 'GET',
|
|
277
|
+
path: '/api/agents/status',
|
|
278
|
+
response: '{ mode, enabled, unread_count }'
|
|
279
|
+
},
|
|
280
|
+
discoverAgents: {
|
|
281
|
+
method: 'GET',
|
|
282
|
+
path: '/api/agents/messageable',
|
|
283
|
+
description: 'Discover which agents you can message',
|
|
284
|
+
response: '{ mode, agents: [{ name }, ...] }'
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
modes: {
|
|
288
|
+
off: 'Messaging disabled - agents cannot communicate',
|
|
289
|
+
supervised: 'Messages require human approval before delivery',
|
|
290
|
+
open: 'Messages delivered immediately without approval'
|
|
291
|
+
},
|
|
292
|
+
notes: [
|
|
293
|
+
'Agent names are case-insensitive (e.g., "WorkBot" and "workbot" are the same)',
|
|
294
|
+
'Agents cannot message themselves',
|
|
295
|
+
'Maximum message length is 10KB'
|
|
296
|
+
]
|
|
297
|
+
};
|
|
298
|
+
})()
|
|
237
299
|
});
|
|
238
300
|
});
|
|
239
301
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Agent notification delivery - sends webhooks to agent gateways
|
|
2
|
+
import { getApiKeyByName, updateQueueNotification } from './db.js';
|
|
3
|
+
|
|
4
|
+
// Send a notification to an agent's webhook
|
|
5
|
+
export async function notifyAgent(agentName, payload) {
|
|
6
|
+
const agent = getApiKeyByName(agentName);
|
|
7
|
+
|
|
8
|
+
if (!agent) {
|
|
9
|
+
return { success: false, error: `Agent "${agentName}" not found` };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!agent.webhook_url) {
|
|
13
|
+
return { success: false, error: `Agent "${agentName}" has no webhook configured` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const headers = {
|
|
18
|
+
'Content-Type': 'application/json'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (agent.webhook_token) {
|
|
22
|
+
headers['Authorization'] = `Bearer ${agent.webhook_token}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const response = await fetch(agent.webhook_url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers,
|
|
28
|
+
body: JSON.stringify(payload)
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
return { success: true };
|
|
33
|
+
} else {
|
|
34
|
+
const text = await response.text().catch(() => '');
|
|
35
|
+
return { success: false, error: `HTTP ${response.status}: ${text.substring(0, 200)}` };
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { success: false, error: err.message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Notify agent about a queue item status change
|
|
43
|
+
export async function notifyAgentQueueStatus(entry) {
|
|
44
|
+
const agentName = entry.submitted_by;
|
|
45
|
+
if (!agentName) {
|
|
46
|
+
return { success: false, error: 'No submitter on queue entry' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const agent = getApiKeyByName(agentName);
|
|
50
|
+
if (!agent?.webhook_url) {
|
|
51
|
+
// Agent doesn't have webhook configured - that's ok, just skip
|
|
52
|
+
return { success: false, error: 'No webhook configured' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const statusEmoji = {
|
|
56
|
+
completed: 'ā
',
|
|
57
|
+
failed: 'ā',
|
|
58
|
+
rejected: 'š«'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const payload = {
|
|
62
|
+
type: 'queue_status',
|
|
63
|
+
entry: {
|
|
64
|
+
id: entry.id,
|
|
65
|
+
service: entry.service,
|
|
66
|
+
account_name: entry.account_name,
|
|
67
|
+
status: entry.status,
|
|
68
|
+
comment: entry.comment,
|
|
69
|
+
rejection_reason: entry.rejection_reason,
|
|
70
|
+
results: entry.results
|
|
71
|
+
},
|
|
72
|
+
// Also include a human-readable message for Clawdbot-style gateways
|
|
73
|
+
text: `${statusEmoji[entry.status] || 'š'} [agentgate] Queue #${entry.id.substring(0, 8)} ${entry.status}\nā ${entry.service}/${entry.account_name}${entry.rejection_reason ? `\nReason: ${entry.rejection_reason}` : ''}${entry.comment ? `\nOriginal: "${entry.comment.substring(0, 100)}"` : ''}`,
|
|
74
|
+
mode: 'now'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = await notifyAgent(agentName, payload);
|
|
78
|
+
|
|
79
|
+
// Update notification status in db
|
|
80
|
+
if (result.success) {
|
|
81
|
+
updateQueueNotification(entry.id, true);
|
|
82
|
+
} else {
|
|
83
|
+
updateQueueNotification(entry.id, false, result.error);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Notify agent about a new message (delivered to recipient)
|
|
90
|
+
export async function notifyAgentMessage(message) {
|
|
91
|
+
const agentName = message.to_agent;
|
|
92
|
+
|
|
93
|
+
const agent = getApiKeyByName(agentName);
|
|
94
|
+
if (!agent?.webhook_url) {
|
|
95
|
+
return { success: false, error: 'No webhook configured' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const payload = {
|
|
99
|
+
type: 'agent_message',
|
|
100
|
+
message: {
|
|
101
|
+
id: message.id,
|
|
102
|
+
from: message.from_agent,
|
|
103
|
+
message: message.message,
|
|
104
|
+
created_at: message.created_at,
|
|
105
|
+
delivered_at: message.delivered_at
|
|
106
|
+
},
|
|
107
|
+
// Human-readable for Clawdbot-style gateways
|
|
108
|
+
text: `š¬ [agentgate] Message from ${message.from_agent}:\n${message.message.substring(0, 500)}`,
|
|
109
|
+
mode: 'now'
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return notifyAgent(agentName, payload);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Notify sender that their message was rejected
|
|
116
|
+
export async function notifyMessageRejected(message) {
|
|
117
|
+
const agentName = message.from_agent;
|
|
118
|
+
|
|
119
|
+
const agent = getApiKeyByName(agentName);
|
|
120
|
+
if (!agent?.webhook_url) {
|
|
121
|
+
return { success: false, error: 'No webhook configured' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const payload = {
|
|
125
|
+
type: 'message_rejected',
|
|
126
|
+
message: {
|
|
127
|
+
id: message.id,
|
|
128
|
+
to: message.to_agent,
|
|
129
|
+
message: message.message,
|
|
130
|
+
rejection_reason: message.rejection_reason,
|
|
131
|
+
created_at: message.created_at,
|
|
132
|
+
rejected_at: message.reviewed_at
|
|
133
|
+
},
|
|
134
|
+
// Human-readable for Clawdbot-style gateways
|
|
135
|
+
text: `š« [agentgate] Message to ${message.to_agent} was rejected${message.rejection_reason ? `\nReason: ${message.rejection_reason}` : ''}\nOriginal: "${message.message.substring(0, 200)}"`,
|
|
136
|
+
mode: 'now'
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return notifyAgent(agentName, payload);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Batch notify multiple agents about their messages
|
|
143
|
+
export async function notifyAgentMessagesBatch(messages) {
|
|
144
|
+
const results = [];
|
|
145
|
+
for (const msg of messages) {
|
|
146
|
+
const result = await notifyAgentMessage(msg);
|
|
147
|
+
results.push({ messageId: msg.id, ...result });
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
package/src/lib/db.js
CHANGED
|
@@ -47,6 +47,22 @@ db.exec(`
|
|
|
47
47
|
notified_at TEXT,
|
|
48
48
|
notify_error TEXT
|
|
49
49
|
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS agent_messages (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
from_agent TEXT NOT NULL,
|
|
54
|
+
to_agent TEXT NOT NULL,
|
|
55
|
+
message TEXT NOT NULL,
|
|
56
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
57
|
+
rejection_reason TEXT,
|
|
58
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
59
|
+
reviewed_at TEXT,
|
|
60
|
+
delivered_at TEXT,
|
|
61
|
+
read_at TEXT
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_agent_messages_recipient
|
|
65
|
+
ON agent_messages(to_agent, status);
|
|
50
66
|
`);
|
|
51
67
|
|
|
52
68
|
// Migrate write_queue table to add notification columns
|
|
@@ -71,19 +87,23 @@ try {
|
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
// Initialize api_keys table with migration support for old schema
|
|
74
|
-
//
|
|
75
|
-
//
|
|
90
|
+
// Schema evolution:
|
|
91
|
+
// v1: id, name, key, created_at
|
|
92
|
+
// v2: id, name, key_prefix, key_hash, created_at
|
|
93
|
+
// v3: + webhook_url, webhook_token (for agent configurations)
|
|
76
94
|
try {
|
|
77
95
|
const tableInfo = db.prepare('PRAGMA table_info(api_keys)').all();
|
|
78
96
|
|
|
79
97
|
if (tableInfo.length === 0) {
|
|
80
|
-
// Table doesn't exist, create with
|
|
98
|
+
// Table doesn't exist, create with latest schema
|
|
81
99
|
db.exec(`
|
|
82
100
|
CREATE TABLE api_keys (
|
|
83
101
|
id TEXT PRIMARY KEY,
|
|
84
|
-
name TEXT NOT NULL,
|
|
102
|
+
name TEXT NOT NULL UNIQUE,
|
|
85
103
|
key_prefix TEXT NOT NULL,
|
|
86
104
|
key_hash TEXT NOT NULL,
|
|
105
|
+
webhook_url TEXT,
|
|
106
|
+
webhook_token TEXT,
|
|
87
107
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
88
108
|
);
|
|
89
109
|
`);
|
|
@@ -99,22 +119,46 @@ try {
|
|
|
99
119
|
DROP TABLE api_keys;
|
|
100
120
|
CREATE TABLE api_keys (
|
|
101
121
|
id TEXT PRIMARY KEY,
|
|
102
|
-
name TEXT NOT NULL,
|
|
122
|
+
name TEXT NOT NULL UNIQUE,
|
|
103
123
|
key_prefix TEXT NOT NULL,
|
|
104
124
|
key_hash TEXT NOT NULL,
|
|
125
|
+
webhook_url TEXT,
|
|
126
|
+
webhook_token TEXT,
|
|
105
127
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
106
128
|
);
|
|
107
129
|
`);
|
|
108
130
|
console.log('Migration complete.');
|
|
131
|
+
} else {
|
|
132
|
+
// Check if we need to add webhook columns (v2 -> v3 migration)
|
|
133
|
+
const hasWebhookUrl = tableInfo.some(col => col.name === 'webhook_url');
|
|
134
|
+
if (!hasWebhookUrl) {
|
|
135
|
+
console.log('Adding webhook columns to api_keys table...');
|
|
136
|
+
db.exec(`
|
|
137
|
+
ALTER TABLE api_keys ADD COLUMN webhook_url TEXT;
|
|
138
|
+
ALTER TABLE api_keys ADD COLUMN webhook_token TEXT;
|
|
139
|
+
`);
|
|
140
|
+
console.log('Webhook columns added.');
|
|
141
|
+
}
|
|
109
142
|
}
|
|
110
|
-
// else: table exists with new schema, nothing to do
|
|
111
143
|
}
|
|
112
144
|
} catch (err) {
|
|
113
145
|
console.error('Error initializing api_keys table:', err.message);
|
|
114
146
|
}
|
|
115
147
|
|
|
116
148
|
// API Keys
|
|
149
|
+
|
|
150
|
+
// Check if an agent name already exists (case-insensitive)
|
|
151
|
+
export function agentNameExists(name) {
|
|
152
|
+
const result = db.prepare('SELECT id FROM api_keys WHERE LOWER(name) = LOWER(?)').get(name);
|
|
153
|
+
return !!result;
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
export async function createApiKey(name) {
|
|
157
|
+
// Check for duplicate names (case-insensitive)
|
|
158
|
+
if (agentNameExists(name)) {
|
|
159
|
+
throw new Error(`An agent with name "${name}" already exists (names are case-insensitive)`);
|
|
160
|
+
}
|
|
161
|
+
|
|
118
162
|
const id = nanoid();
|
|
119
163
|
const key = `rms_${nanoid(32)}`;
|
|
120
164
|
const keyPrefix = key.substring(0, 8) + '...' + key.substring(key.length - 4);
|
|
@@ -124,20 +168,33 @@ export async function createApiKey(name) {
|
|
|
124
168
|
}
|
|
125
169
|
|
|
126
170
|
export function listApiKeys() {
|
|
127
|
-
return db.prepare('SELECT id, name, key_prefix, created_at FROM api_keys').all();
|
|
171
|
+
return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys').all();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getApiKeyByName(name) {
|
|
175
|
+
// Case-insensitive lookup
|
|
176
|
+
return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys WHERE LOWER(name) = LOWER(?)').get(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getApiKeyById(id) {
|
|
180
|
+
return db.prepare('SELECT id, name, key_prefix, webhook_url, webhook_token, created_at FROM api_keys WHERE id = ?').get(id);
|
|
128
181
|
}
|
|
129
182
|
|
|
130
183
|
export function deleteApiKey(id) {
|
|
131
184
|
return db.prepare('DELETE FROM api_keys WHERE id = ?').run(id);
|
|
132
185
|
}
|
|
133
186
|
|
|
187
|
+
export function updateAgentWebhook(id, webhookUrl, webhookToken) {
|
|
188
|
+
return db.prepare('UPDATE api_keys SET webhook_url = ?, webhook_token = ? WHERE id = ?').run(webhookUrl || null, webhookToken || null, id);
|
|
189
|
+
}
|
|
190
|
+
|
|
134
191
|
export async function validateApiKey(key) {
|
|
135
192
|
// Must check all keys since we can't look up by hash directly
|
|
136
193
|
const allKeys = db.prepare('SELECT * FROM api_keys').all();
|
|
137
194
|
for (const row of allKeys) {
|
|
138
195
|
const match = await bcrypt.compare(key, row.key_hash);
|
|
139
196
|
if (match) {
|
|
140
|
-
return { id: row.id, name: row.name };
|
|
197
|
+
return { id: row.id, name: row.name, webhookUrl: row.webhook_url, webhookToken: row.webhook_token };
|
|
141
198
|
}
|
|
142
199
|
}
|
|
143
200
|
return null;
|
|
@@ -382,4 +439,124 @@ export function listQueueEntriesBySubmitter(submittedBy, service = null, account
|
|
|
382
439
|
return db.prepare(sql).all(params);
|
|
383
440
|
}
|
|
384
441
|
|
|
442
|
+
// Agent Messaging
|
|
443
|
+
|
|
444
|
+
// Get messaging mode: 'off', 'supervised', 'open'
|
|
445
|
+
export function getMessagingMode() {
|
|
446
|
+
const setting = getSetting('agent_messaging');
|
|
447
|
+
return setting?.mode || 'off';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function setMessagingMode(mode) {
|
|
451
|
+
if (!['off', 'supervised', 'open'].includes(mode)) {
|
|
452
|
+
throw new Error('Invalid messaging mode');
|
|
453
|
+
}
|
|
454
|
+
setSetting('agent_messaging', { mode });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function createAgentMessage(fromAgent, toAgent, message) {
|
|
458
|
+
const id = nanoid();
|
|
459
|
+
const mode = getMessagingMode();
|
|
460
|
+
|
|
461
|
+
if (mode === 'off') {
|
|
462
|
+
throw new Error('Agent messaging is disabled');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// In open mode, messages are delivered immediately
|
|
466
|
+
const status = mode === 'open' ? 'delivered' : 'pending';
|
|
467
|
+
const deliveredAt = mode === 'open' ? new Date().toISOString() : null;
|
|
468
|
+
|
|
469
|
+
db.prepare(`
|
|
470
|
+
INSERT INTO agent_messages (id, from_agent, to_agent, message, status, delivered_at)
|
|
471
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
472
|
+
`).run(id, fromAgent, toAgent, message, status, deliveredAt);
|
|
473
|
+
|
|
474
|
+
return { id, status };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function getAgentMessage(id) {
|
|
478
|
+
return db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(id);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Get messages for a specific agent (recipient)
|
|
482
|
+
export function getMessagesForAgent(agentName, unreadOnly = false) {
|
|
483
|
+
let sql = `
|
|
484
|
+
SELECT * FROM agent_messages
|
|
485
|
+
WHERE to_agent = ? AND status = 'delivered'
|
|
486
|
+
`;
|
|
487
|
+
if (unreadOnly) {
|
|
488
|
+
sql += ' AND read_at IS NULL';
|
|
489
|
+
}
|
|
490
|
+
sql += ' ORDER BY created_at DESC';
|
|
491
|
+
return db.prepare(sql).all(agentName);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Mark message as read
|
|
495
|
+
export function markMessageRead(id, agentName) {
|
|
496
|
+
return db.prepare(`
|
|
497
|
+
UPDATE agent_messages
|
|
498
|
+
SET read_at = CURRENT_TIMESTAMP
|
|
499
|
+
WHERE id = ? AND to_agent = ? AND read_at IS NULL
|
|
500
|
+
`).run(id, agentName);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Admin: list pending messages (for supervised mode)
|
|
504
|
+
export function listPendingMessages() {
|
|
505
|
+
return db.prepare(`
|
|
506
|
+
SELECT * FROM agent_messages
|
|
507
|
+
WHERE status = 'pending'
|
|
508
|
+
ORDER BY created_at DESC
|
|
509
|
+
`).all();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Admin: approve message
|
|
513
|
+
export function approveAgentMessage(id) {
|
|
514
|
+
return db.prepare(`
|
|
515
|
+
UPDATE agent_messages
|
|
516
|
+
SET status = 'delivered', reviewed_at = CURRENT_TIMESTAMP, delivered_at = CURRENT_TIMESTAMP
|
|
517
|
+
WHERE id = ? AND status = 'pending'
|
|
518
|
+
`).run(id);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Admin: reject message
|
|
522
|
+
export function rejectAgentMessage(id, reason) {
|
|
523
|
+
return db.prepare(`
|
|
524
|
+
UPDATE agent_messages
|
|
525
|
+
SET status = 'rejected', reviewed_at = CURRENT_TIMESTAMP, rejection_reason = ?
|
|
526
|
+
WHERE id = ? AND status = 'pending'
|
|
527
|
+
`).run(reason || 'No reason provided', id);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Admin: list all messages (for UI)
|
|
531
|
+
export function listAgentMessages(status = null) {
|
|
532
|
+
if (status) {
|
|
533
|
+
return db.prepare('SELECT * FROM agent_messages WHERE status = ? ORDER BY created_at DESC').all(status);
|
|
534
|
+
}
|
|
535
|
+
return db.prepare('SELECT * FROM agent_messages ORDER BY created_at DESC').all();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Admin: delete message
|
|
539
|
+
export function deleteAgentMessage(id) {
|
|
540
|
+
return db.prepare('DELETE FROM agent_messages WHERE id = ?').run(id);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Admin: clear messages by status
|
|
544
|
+
export function clearAgentMessagesByStatus(status) {
|
|
545
|
+
if (status === 'all') {
|
|
546
|
+
return db.prepare("DELETE FROM agent_messages WHERE status IN ('delivered', 'rejected')").run();
|
|
547
|
+
}
|
|
548
|
+
return db.prepare('DELETE FROM agent_messages WHERE status = ?').run(status);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Get counts for message queue
|
|
552
|
+
export function getMessageCounts() {
|
|
553
|
+
const rows = db.prepare('SELECT status, COUNT(*) as count FROM agent_messages GROUP BY status').all();
|
|
554
|
+
const counts = { all: 0, pending: 0, delivered: 0, rejected: 0 };
|
|
555
|
+
for (const row of rows) {
|
|
556
|
+
counts[row.status] = row.count;
|
|
557
|
+
counts.all += row.count;
|
|
558
|
+
}
|
|
559
|
+
return counts;
|
|
560
|
+
}
|
|
561
|
+
|
|
385
562
|
export default db;
|
package/src/lib/notifier.js
CHANGED
|
@@ -7,6 +7,30 @@ import { getSetting, updateQueueNotification } from './db.js';
|
|
|
7
7
|
const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
8
8
|
const DEFAULT_RETRY_DELAY_MS = 5000;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Find the most relevant result to display (usually last one with a URL, or first failure)
|
|
12
|
+
*/
|
|
13
|
+
function findRelevantResult(results, forError = false) {
|
|
14
|
+
if (!results?.length) return null;
|
|
15
|
+
|
|
16
|
+
if (forError) {
|
|
17
|
+
// For errors, find the first non-ok result
|
|
18
|
+
return results.find(r => !r.ok) || results[results.length - 1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// For success, find the last result with a useful URL (PR, issue, etc.)
|
|
22
|
+
// Search backwards to get the most relevant one (e.g., PR URL, not branch ref)
|
|
23
|
+
for (let i = results.length - 1; i >= 0; i--) {
|
|
24
|
+
const r = results[i];
|
|
25
|
+
if (r.body?.html_url || r.body?.url) {
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback to last result
|
|
31
|
+
return results[results.length - 1];
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/**
|
|
11
35
|
* Format the notification text for a queue entry
|
|
12
36
|
*/
|
|
@@ -18,29 +42,29 @@ function formatNotification(entry) {
|
|
|
18
42
|
text += `\nā ${entry.service}/${entry.account_name}`;
|
|
19
43
|
|
|
20
44
|
// Include key result info (e.g., PR URL, issue URL)
|
|
21
|
-
if (entry.results?.length) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
45
|
+
if (entry.status === 'completed' && entry.results?.length) {
|
|
46
|
+
const relevantResult = findRelevantResult(entry.results, false);
|
|
47
|
+
if (relevantResult?.body) {
|
|
24
48
|
// GitHub PR/Issue
|
|
25
|
-
if (
|
|
26
|
-
text += `\nā ${
|
|
49
|
+
if (relevantResult.body.html_url) {
|
|
50
|
+
text += `\nā ${relevantResult.body.html_url}`;
|
|
27
51
|
}
|
|
28
52
|
// Other useful fields
|
|
29
|
-
else if (
|
|
30
|
-
text += `\nā ${
|
|
53
|
+
else if (relevantResult.body.url) {
|
|
54
|
+
text += `\nā ${relevantResult.body.url}`;
|
|
31
55
|
}
|
|
32
56
|
}
|
|
33
57
|
}
|
|
34
58
|
|
|
35
59
|
// Include error info for failures
|
|
36
60
|
if (entry.status === 'failed' && entry.results?.length) {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
text += `\nā Error: ${
|
|
40
|
-
} else if (
|
|
41
|
-
text += `\nā Error: ${
|
|
42
|
-
} else if (
|
|
43
|
-
text += `\nā Error: HTTP ${
|
|
61
|
+
const failingResult = findRelevantResult(entry.results, true);
|
|
62
|
+
if (failingResult.error) {
|
|
63
|
+
text += `\nā Error: ${failingResult.error}`;
|
|
64
|
+
} else if (failingResult.body?.message) {
|
|
65
|
+
text += `\nā Error: ${failingResult.body.message}`;
|
|
66
|
+
} else if (!failingResult.ok && failingResult.status) {
|
|
67
|
+
text += `\nā Error: HTTP ${failingResult.status}`;
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
70
|
|
|
@@ -166,10 +190,14 @@ function formatBatchLine(entry) {
|
|
|
166
190
|
let line = `${emoji} #${entry.id.substring(0, 8)} - ${entry.service}/${entry.account_name}`;
|
|
167
191
|
|
|
168
192
|
// Add brief result info
|
|
169
|
-
if (entry.status === 'completed'
|
|
170
|
-
|
|
193
|
+
if (entry.status === 'completed') {
|
|
194
|
+
const relevantResult = findRelevantResult(entry.results, false);
|
|
195
|
+
if (relevantResult?.body?.html_url) {
|
|
196
|
+
line += ` - ${relevantResult.body.html_url}`;
|
|
197
|
+
}
|
|
171
198
|
} else if (entry.status === 'failed') {
|
|
172
|
-
const
|
|
199
|
+
const failingResult = findRelevantResult(entry.results, true);
|
|
200
|
+
const err = failingResult?.error || failingResult?.body?.message || (failingResult && !failingResult.ok ? `HTTP ${failingResult.status || '?'}` : 'Unknown error');
|
|
173
201
|
line += ` - ${err.substring(0, 50)}`;
|
|
174
202
|
} else if (entry.status === 'rejected') {
|
|
175
203
|
line += ` - ${(entry.rejection_reason || 'rejected').substring(0, 50)}`;
|