sentinel-mcp 0.1.2 → 0.2.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/dist/index.js +75 -6
- package/dist/queue.d.ts +40 -0
- package/dist/queue.js +123 -0
- package/dist/types.d.ts +33 -1
- package/dist/ws-client.d.ts +1 -1
- package/dist/ws-client.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { WsClient, decodeInstallationId } from "./ws-client.js";
|
|
9
|
+
import { EventQueue } from "./queue.js";
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Config from environment
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +37,8 @@ try {
|
|
|
36
37
|
catch {
|
|
37
38
|
log("WARNING: Could not resolve repo name from git remote — events will not be filtered by repo");
|
|
38
39
|
}
|
|
40
|
+
const MAX_CONCURRENCY = parseInt(process.env.SENTINEL_MAX_CONCURRENCY ?? "3", 10);
|
|
41
|
+
const eventQueue = new EventQueue({ maxConcurrency: MAX_CONCURRENCY, recoverActive: true });
|
|
39
42
|
// ---------------------------------------------------------------------------
|
|
40
43
|
// MCP server setup
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
@@ -59,6 +62,21 @@ function pushNotification(content, meta) {
|
|
|
59
62
|
log(`Failed to push notification: ${err.message}`);
|
|
60
63
|
});
|
|
61
64
|
}
|
|
65
|
+
function updateEventStatus(usageId, status) {
|
|
66
|
+
if (!usageId)
|
|
67
|
+
return;
|
|
68
|
+
const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
|
|
69
|
+
fetch(`${httpUrl}/api/dashboard/events/${usageId}/status`, {
|
|
70
|
+
method: "PUT",
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
Authorization: `Bearer ${SENTINEL_TOKEN}`,
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({ status }),
|
|
76
|
+
}).catch((err) => {
|
|
77
|
+
log(`Failed to update event status: ${err.message}`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
62
80
|
// ---------------------------------------------------------------------------
|
|
63
81
|
// GitHub token management
|
|
64
82
|
// ---------------------------------------------------------------------------
|
|
@@ -131,16 +149,22 @@ const wsClient = new WsClient({
|
|
|
131
149
|
relayUrl: RELAY_URL,
|
|
132
150
|
repo: REPO_NAME || undefined,
|
|
133
151
|
log,
|
|
134
|
-
onEvent: (id, payload, notification) => {
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
152
|
+
onEvent: (id, payload, notification, usageId) => {
|
|
153
|
+
const added = eventQueue.enqueue(id, usageId ?? null, payload, notification ?? null);
|
|
154
|
+
if (!added) {
|
|
155
|
+
log(`Duplicate event ${id}, skipping`);
|
|
138
156
|
}
|
|
139
157
|
else {
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
updateEventStatus(usageId ?? null, "pending");
|
|
159
|
+
log(`Queued event ${id} (${payload.type} #${payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number}) — queue: ${eventQueue.queueLength()}`);
|
|
142
160
|
}
|
|
161
|
+
// ACK immediately (relay considers it delivered to MCP client)
|
|
143
162
|
wsClient.ack(id);
|
|
163
|
+
// If capacity available, nudge Claude
|
|
164
|
+
if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
165
|
+
const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
|
|
166
|
+
pushNotification(`New event queued: issue #${issueNum} in ${payload.repo} (${payload.type}). Call claim_event to start processing.`, { type: "queue_nudge", queueLength: String(eventQueue.queueLength()) });
|
|
167
|
+
}
|
|
144
168
|
},
|
|
145
169
|
onDisplaced: (reason) => {
|
|
146
170
|
pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
|
|
@@ -235,6 +259,51 @@ server.registerTool("get_skill", {
|
|
|
235
259
|
const result = await mcpToolCall("get_skill", { name });
|
|
236
260
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
237
261
|
});
|
|
262
|
+
server.registerTool("claim_event", {
|
|
263
|
+
description: "Claim the next queued event for processing. Returns event details or null if queue is empty.",
|
|
264
|
+
}, async () => {
|
|
265
|
+
const event = eventQueue.claim();
|
|
266
|
+
if (!event) {
|
|
267
|
+
return { content: [{ type: "text", text: JSON.stringify(null) }] };
|
|
268
|
+
}
|
|
269
|
+
updateEventStatus(event.usageId, "processing");
|
|
270
|
+
return {
|
|
271
|
+
content: [{
|
|
272
|
+
type: "text",
|
|
273
|
+
text: JSON.stringify({
|
|
274
|
+
id: event.id,
|
|
275
|
+
usageId: event.usageId,
|
|
276
|
+
notification: event.notification,
|
|
277
|
+
payload: event.payload,
|
|
278
|
+
}),
|
|
279
|
+
}],
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
server.registerTool("release_event", {
|
|
283
|
+
description: "Release an event after processing completes (success or failure). Triggers next event if queue is non-empty.",
|
|
284
|
+
inputSchema: {
|
|
285
|
+
id: z.string().describe("Event ID returned by claim_event"),
|
|
286
|
+
},
|
|
287
|
+
}, async ({ id }) => {
|
|
288
|
+
const released = eventQueue.release(id);
|
|
289
|
+
if (!released) {
|
|
290
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found in active list" }) }] };
|
|
291
|
+
}
|
|
292
|
+
// If queue has items and capacity available, nudge Claude
|
|
293
|
+
if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
|
|
294
|
+
const status = eventQueue.status();
|
|
295
|
+
const next = status.queue[0];
|
|
296
|
+
pushNotification(`Event released. Next in queue: issue #${next.issueNumber} in ${next.repo} (${next.eventType}). Call claim_event to process.`, { type: "queue_nudge", queueLength: String(status.queueLength) });
|
|
297
|
+
}
|
|
298
|
+
return { content: [{ type: "text", text: JSON.stringify({ released: true }) }] };
|
|
299
|
+
});
|
|
300
|
+
server.registerTool("queue_status", {
|
|
301
|
+
description: "Get the current event queue status — queue length, active count, and details.",
|
|
302
|
+
}, async () => {
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: JSON.stringify(eventQueue.status(), null, 2) }],
|
|
305
|
+
};
|
|
306
|
+
});
|
|
238
307
|
// ---------------------------------------------------------------------------
|
|
239
308
|
// Startup
|
|
240
309
|
// ---------------------------------------------------------------------------
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { QueuedEvent, SentinelEvent } from "./types.js";
|
|
2
|
+
export interface EventQueueOptions {
|
|
3
|
+
queueFilePath?: string;
|
|
4
|
+
maxConcurrency?: number;
|
|
5
|
+
recoverActive?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare class EventQueue {
|
|
8
|
+
private state;
|
|
9
|
+
private filePath;
|
|
10
|
+
constructor(opts?: EventQueueOptions);
|
|
11
|
+
enqueue(id: string, usageId: string | null, payload: SentinelEvent, notification: {
|
|
12
|
+
content: string;
|
|
13
|
+
meta: Record<string, string>;
|
|
14
|
+
} | null): boolean;
|
|
15
|
+
claim(): QueuedEvent | null;
|
|
16
|
+
release(id: string): boolean;
|
|
17
|
+
hasCapacity(): boolean;
|
|
18
|
+
queueLength(): number;
|
|
19
|
+
status(): {
|
|
20
|
+
queueLength: number;
|
|
21
|
+
activeCount: number;
|
|
22
|
+
maxConcurrency: number;
|
|
23
|
+
queue: Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
eventType: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
issueNumber: number;
|
|
28
|
+
priority: string;
|
|
29
|
+
enqueuedAt: string;
|
|
30
|
+
}>;
|
|
31
|
+
active: Array<{
|
|
32
|
+
id: string;
|
|
33
|
+
repo: string;
|
|
34
|
+
issueNumber: number;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
private load;
|
|
39
|
+
private persist;
|
|
40
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
export class EventQueue {
|
|
5
|
+
state;
|
|
6
|
+
filePath;
|
|
7
|
+
constructor(opts = {}) {
|
|
8
|
+
this.filePath = opts.queueFilePath ?? path.join(os.homedir(), ".sentinel", "queue.json");
|
|
9
|
+
const maxConcurrency = opts.maxConcurrency ?? 3;
|
|
10
|
+
this.state = this.load(maxConcurrency);
|
|
11
|
+
if (opts.recoverActive && this.state.active.length > 0) {
|
|
12
|
+
for (const active of this.state.active) {
|
|
13
|
+
this.state.queue.unshift({
|
|
14
|
+
id: active.id,
|
|
15
|
+
usageId: active.usageId,
|
|
16
|
+
priority: "high",
|
|
17
|
+
eventType: active.payload.type,
|
|
18
|
+
repo: active.repo,
|
|
19
|
+
issueNumber: active.issueNumber,
|
|
20
|
+
notification: active.notification,
|
|
21
|
+
payload: active.payload,
|
|
22
|
+
enqueuedAt: new Date().toISOString(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
this.state.active = [];
|
|
26
|
+
this.persist();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
enqueue(id, usageId, payload, notification) {
|
|
30
|
+
if (this.state.queue.some((e) => e.id === id) || this.state.active.some((e) => e.id === id)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const issueNumber = payload.type === "pr_merged" ? payload.issue_number : payload.issue.number;
|
|
34
|
+
const isFollowUp = this.state.active.some((a) => a.issueNumber === issueNumber && a.repo === payload.repo);
|
|
35
|
+
const priority = isFollowUp ? "high" : "normal";
|
|
36
|
+
const entry = {
|
|
37
|
+
id,
|
|
38
|
+
usageId,
|
|
39
|
+
priority,
|
|
40
|
+
eventType: payload.type,
|
|
41
|
+
repo: payload.repo,
|
|
42
|
+
issueNumber,
|
|
43
|
+
notification,
|
|
44
|
+
payload,
|
|
45
|
+
enqueuedAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
if (priority === "high") {
|
|
48
|
+
const firstNormal = this.state.queue.findIndex((e) => e.priority === "normal");
|
|
49
|
+
if (firstNormal === -1) {
|
|
50
|
+
this.state.queue.push(entry);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
this.state.queue.splice(firstNormal, 0, entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
this.state.queue.push(entry);
|
|
58
|
+
}
|
|
59
|
+
this.persist();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
claim() {
|
|
63
|
+
if (this.state.queue.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
const event = this.state.queue.shift();
|
|
66
|
+
this.state.active.push({
|
|
67
|
+
id: event.id,
|
|
68
|
+
usageId: event.usageId,
|
|
69
|
+
issueNumber: event.issueNumber,
|
|
70
|
+
repo: event.repo,
|
|
71
|
+
notification: event.notification,
|
|
72
|
+
payload: event.payload,
|
|
73
|
+
startedAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
this.persist();
|
|
76
|
+
return event;
|
|
77
|
+
}
|
|
78
|
+
release(id) {
|
|
79
|
+
const idx = this.state.active.findIndex((e) => e.id === id);
|
|
80
|
+
if (idx === -1)
|
|
81
|
+
return false;
|
|
82
|
+
this.state.active.splice(idx, 1);
|
|
83
|
+
this.persist();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
hasCapacity() {
|
|
87
|
+
return this.state.active.length < this.state.maxConcurrency;
|
|
88
|
+
}
|
|
89
|
+
queueLength() {
|
|
90
|
+
return this.state.queue.length;
|
|
91
|
+
}
|
|
92
|
+
status() {
|
|
93
|
+
return {
|
|
94
|
+
queueLength: this.state.queue.length,
|
|
95
|
+
activeCount: this.state.active.length,
|
|
96
|
+
maxConcurrency: this.state.maxConcurrency,
|
|
97
|
+
queue: this.state.queue.map((e) => ({
|
|
98
|
+
id: e.id, eventType: e.eventType, repo: e.repo,
|
|
99
|
+
issueNumber: e.issueNumber, priority: e.priority, enqueuedAt: e.enqueuedAt,
|
|
100
|
+
})),
|
|
101
|
+
active: this.state.active.map((e) => ({
|
|
102
|
+
id: e.id, repo: e.repo, issueNumber: e.issueNumber, startedAt: e.startedAt,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
load(maxConcurrency) {
|
|
107
|
+
try {
|
|
108
|
+
const data = fs.readFileSync(this.filePath, "utf-8");
|
|
109
|
+
const parsed = JSON.parse(data);
|
|
110
|
+
return { ...parsed, maxConcurrency: parsed.maxConcurrency ?? maxConcurrency };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { queue: [], active: [], maxConcurrency };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
persist() {
|
|
117
|
+
const dir = path.dirname(this.filePath);
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
const tmpPath = this.filePath + ".tmp";
|
|
120
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.state, null, 2));
|
|
121
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface IssueEvent {
|
|
2
|
-
type: "issue_opened" | "issue_comment" | "issue_closed";
|
|
2
|
+
type: "issue_opened" | "issue_labeled" | "issue_comment" | "issue_closed";
|
|
3
3
|
repo: string;
|
|
4
4
|
issue: {
|
|
5
5
|
number: number;
|
|
@@ -29,6 +29,7 @@ export type ServerMessage = {
|
|
|
29
29
|
type: "event";
|
|
30
30
|
id: string;
|
|
31
31
|
payload: SentinelEvent;
|
|
32
|
+
usageId?: string;
|
|
32
33
|
notification?: {
|
|
33
34
|
content: string;
|
|
34
35
|
meta: Record<string, string>;
|
|
@@ -56,3 +57,34 @@ export type ClientMessage = {
|
|
|
56
57
|
issue: number;
|
|
57
58
|
session_id: string;
|
|
58
59
|
};
|
|
60
|
+
export interface QueuedEvent {
|
|
61
|
+
id: string;
|
|
62
|
+
usageId: string | null;
|
|
63
|
+
priority: "high" | "normal";
|
|
64
|
+
eventType: string;
|
|
65
|
+
repo: string;
|
|
66
|
+
issueNumber: number;
|
|
67
|
+
notification: {
|
|
68
|
+
content: string;
|
|
69
|
+
meta: Record<string, string>;
|
|
70
|
+
} | null;
|
|
71
|
+
payload: SentinelEvent;
|
|
72
|
+
enqueuedAt: string;
|
|
73
|
+
}
|
|
74
|
+
export interface ActiveEvent {
|
|
75
|
+
id: string;
|
|
76
|
+
usageId: string | null;
|
|
77
|
+
issueNumber: number;
|
|
78
|
+
repo: string;
|
|
79
|
+
notification: {
|
|
80
|
+
content: string;
|
|
81
|
+
meta: Record<string, string>;
|
|
82
|
+
} | null;
|
|
83
|
+
payload: SentinelEvent;
|
|
84
|
+
startedAt: string;
|
|
85
|
+
}
|
|
86
|
+
export interface QueueState {
|
|
87
|
+
queue: QueuedEvent[];
|
|
88
|
+
active: ActiveEvent[];
|
|
89
|
+
maxConcurrency: number;
|
|
90
|
+
}
|
package/dist/ws-client.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface WsClientOptions {
|
|
|
20
20
|
onEvent: (id: string, payload: any, notification?: {
|
|
21
21
|
content: string;
|
|
22
22
|
meta: Record<string, string>;
|
|
23
|
-
}) => void;
|
|
23
|
+
}, usageId?: string) => void;
|
|
24
24
|
onDisplaced: (reason: string) => void;
|
|
25
25
|
onLimitReached?: (message: string) => void;
|
|
26
26
|
log: (msg: string) => void;
|
package/dist/ws-client.js
CHANGED
|
@@ -89,7 +89,7 @@ export class WsClient {
|
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
if (msg.type === "event") {
|
|
92
|
-
this.opts.onEvent(msg.id, msg.payload, msg.notification);
|
|
92
|
+
this.opts.onEvent(msg.id, msg.payload, msg.notification, msg.usageId);
|
|
93
93
|
}
|
|
94
94
|
else if (msg.type === "ping") {
|
|
95
95
|
this.send({ type: "pong" });
|