@wolpertingerlabs/drawlatch 1.0.0-alpha.10.0 → 1.0.0-alpha.12.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/connections/developer-tools/github-events-poll.json +78 -0
- package/dist/connections/developer-tools/github.json +47 -2
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +8 -0
- package/dist/remote/ingestors/manager.js +34 -0
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +2 -0
- package/dist/remote/ingestors/poll/poll-ingestor.js +21 -0
- package/dist/remote/ingestors/types.d.ts +6 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +5 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +7 -0
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +6 -6
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/shared/config.d.ts +5 -0
- package/package.json +2 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "GitHub Events API (Poll)",
|
|
3
|
+
"stability": "beta",
|
|
4
|
+
"category": "developer-tools",
|
|
5
|
+
"description": "GitHub Events REST API — polls repository events for push, pull_request, issues, and more. A webhook-free alternative to the GitHub webhook connection. Auth is handled automatically via the GITHUB_TOKEN environment variable. Uses ETag caching to minimize rate limit consumption. Use poll_events to retrieve buffered events.",
|
|
6
|
+
"docsUrl": "https://docs.github.com/en/rest/activity/events",
|
|
7
|
+
"headers": {
|
|
8
|
+
"Authorization": "Bearer ${GITHUB_TOKEN}",
|
|
9
|
+
"Accept": "application/vnd.github+json",
|
|
10
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
11
|
+
"User-Agent": "drawlatch"
|
|
12
|
+
},
|
|
13
|
+
"secrets": {
|
|
14
|
+
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
|
|
15
|
+
},
|
|
16
|
+
"allowedEndpoints": ["https://api.github.com/**"],
|
|
17
|
+
"ingestor": {
|
|
18
|
+
"type": "poll",
|
|
19
|
+
"poll": {
|
|
20
|
+
"url": "https://api.github.com/repos/${GITHUB_POLL_REPO}/events",
|
|
21
|
+
"intervalMs": 60000,
|
|
22
|
+
"method": "GET",
|
|
23
|
+
"deduplicateBy": "id",
|
|
24
|
+
"eventType": "github_event",
|
|
25
|
+
"etag": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"testIngestor": {
|
|
29
|
+
"description": "Executes a single poll of the repository events endpoint to verify access",
|
|
30
|
+
"strategy": "poll_once",
|
|
31
|
+
"request": {
|
|
32
|
+
"method": "GET",
|
|
33
|
+
"url": "https://api.github.com/repos/${GITHUB_POLL_REPO}/events?per_page=1",
|
|
34
|
+
"expectedStatus": [200]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"listenerConfig": {
|
|
38
|
+
"name": "GitHub Events Poll Listener",
|
|
39
|
+
"description": "Polls the GitHub Events API for repository activity. A webhook-free alternative — ideal for environments without a public URL.",
|
|
40
|
+
"supportsMultiInstance": true,
|
|
41
|
+
"fields": [
|
|
42
|
+
{
|
|
43
|
+
"key": "GITHUB_POLL_REPO",
|
|
44
|
+
"label": "Repository",
|
|
45
|
+
"description": "Repository to poll for events (owner/repo format).",
|
|
46
|
+
"type": "text",
|
|
47
|
+
"instanceKey": true,
|
|
48
|
+
"overrideKey": "GITHUB_POLL_REPO",
|
|
49
|
+
"placeholder": "e.g., octocat/Hello-World",
|
|
50
|
+
"group": "Connection"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"key": "intervalMs",
|
|
54
|
+
"label": "Poll Interval (ms)",
|
|
55
|
+
"description": "How often to check for new events, in milliseconds. GitHub caches events for ~60s, so polling faster is wasteful.",
|
|
56
|
+
"type": "number",
|
|
57
|
+
"default": 60000,
|
|
58
|
+
"min": 30000,
|
|
59
|
+
"max": 3600000,
|
|
60
|
+
"group": "Connection"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"key": "bufferSize",
|
|
64
|
+
"label": "Buffer Size",
|
|
65
|
+
"description": "Maximum number of events to keep in memory.",
|
|
66
|
+
"type": "number",
|
|
67
|
+
"default": 200,
|
|
68
|
+
"min": 10,
|
|
69
|
+
"max": 1000,
|
|
70
|
+
"group": "Advanced"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"testConnection": {
|
|
75
|
+
"url": "https://api.github.com/user",
|
|
76
|
+
"description": "Fetches the authenticated user profile"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"secrets": {
|
|
15
15
|
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
|
|
16
|
-
"GITHUB_WEBHOOK_SECRET": "${GITHUB_WEBHOOK_SECRET}"
|
|
16
|
+
"GITHUB_WEBHOOK_SECRET": "${GITHUB_WEBHOOK_SECRET}",
|
|
17
|
+
"GITHUB_WEBHOOK_URL": "${GITHUB_WEBHOOK_URL}"
|
|
17
18
|
},
|
|
18
19
|
"allowedEndpoints": ["https://api.github.com/**"],
|
|
19
20
|
"ingestor": {
|
|
@@ -21,7 +22,51 @@
|
|
|
21
22
|
"webhook": {
|
|
22
23
|
"path": "github",
|
|
23
24
|
"signatureHeader": "X-Hub-Signature-256",
|
|
24
|
-
"signatureSecret": "GITHUB_WEBHOOK_SECRET"
|
|
25
|
+
"signatureSecret": "GITHUB_WEBHOOK_SECRET",
|
|
26
|
+
"callbackUrl": "${GITHUB_WEBHOOK_URL}",
|
|
27
|
+
"lifecycle": {
|
|
28
|
+
"list": {
|
|
29
|
+
"method": "GET",
|
|
30
|
+
"url": "https://api.github.com/repos/${repoFilter}/hooks",
|
|
31
|
+
"headers": {
|
|
32
|
+
"Authorization": "Bearer ${GITHUB_TOKEN}",
|
|
33
|
+
"Accept": "application/vnd.github+json",
|
|
34
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
35
|
+
},
|
|
36
|
+
"callbackUrlField": "config.url",
|
|
37
|
+
"idField": "id"
|
|
38
|
+
},
|
|
39
|
+
"register": {
|
|
40
|
+
"method": "POST",
|
|
41
|
+
"url": "https://api.github.com/repos/${repoFilter}/hooks",
|
|
42
|
+
"headers": {
|
|
43
|
+
"Authorization": "Bearer ${GITHUB_TOKEN}",
|
|
44
|
+
"Accept": "application/vnd.github+json",
|
|
45
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
46
|
+
},
|
|
47
|
+
"body": {
|
|
48
|
+
"name": "web",
|
|
49
|
+
"active": true,
|
|
50
|
+
"events": ["push", "pull_request", "issues", "issue_comment", "create", "delete", "release", "workflow_run", "check_run"],
|
|
51
|
+
"config": {
|
|
52
|
+
"url": "${GITHUB_WEBHOOK_URL}/webhooks/github",
|
|
53
|
+
"content_type": "json",
|
|
54
|
+
"secret": "${GITHUB_WEBHOOK_SECRET}",
|
|
55
|
+
"insecure_ssl": "0"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"idField": "id"
|
|
59
|
+
},
|
|
60
|
+
"unregister": {
|
|
61
|
+
"method": "DELETE",
|
|
62
|
+
"url": "https://api.github.com/repos/${repoFilter}/hooks/${_webhookId}",
|
|
63
|
+
"headers": {
|
|
64
|
+
"Authorization": "Bearer ${GITHUB_TOKEN}",
|
|
65
|
+
"Accept": "application/vnd.github+json",
|
|
66
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
25
70
|
}
|
|
26
71
|
},
|
|
27
72
|
"testIngestor": {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared E2E test setup for connection ingestor tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers to:
|
|
5
|
+
* - Load .env.e2e environment variables
|
|
6
|
+
* - Build a RemoteServerConfig with in-memory keys and requested connections
|
|
7
|
+
* - Boot an Express server on a random port
|
|
8
|
+
* - Generate valid webhook signatures (GitHub, Trello)
|
|
9
|
+
* - Wait for ingestor state transitions and event arrival
|
|
10
|
+
*/
|
|
11
|
+
import type { Server } from 'node:http';
|
|
12
|
+
import { IngestorManager } from '../manager.js';
|
|
13
|
+
import type { RemoteServerConfig } from '../../../shared/config.js';
|
|
14
|
+
import type { IngestedEvent, IngestorState } from '../types.js';
|
|
15
|
+
export declare function loadE2EEnv(): void;
|
|
16
|
+
/**
|
|
17
|
+
* Check that all required env vars are set.
|
|
18
|
+
* Returns the missing var names (empty array = all present).
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkEnvVars(vars: string[]): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Build a RemoteServerConfig for E2E tests.
|
|
23
|
+
*
|
|
24
|
+
* Creates a single caller ("e2e-client") with the requested connections.
|
|
25
|
+
* Env vars are injected into caller.env so secret resolution works against
|
|
26
|
+
* process.env (already loaded by loadE2EEnv).
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildE2EConfig(connections: string[], opts?: {
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
ingestorOverrides?: Record<string, Record<string, unknown>>;
|
|
31
|
+
}): RemoteServerConfig;
|
|
32
|
+
export interface E2EServer {
|
|
33
|
+
server: Server;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
ingestorManager: IngestorManager;
|
|
36
|
+
/** Gracefully shut down server and stop all ingestors. */
|
|
37
|
+
teardown: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Boot an Express server with the given config on a random port.
|
|
41
|
+
* Starts all ingestors and returns handles for testing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function bootServer(config: RemoteServerConfig): Promise<E2EServer>;
|
|
44
|
+
/** Generate a valid GitHub HMAC-SHA256 signature for a raw body. */
|
|
45
|
+
export declare function signGitHubPayload(rawBody: string | Buffer, secret: string): string;
|
|
46
|
+
/** Generate a valid Trello HMAC-SHA1 base64 signature for body + callbackUrl. */
|
|
47
|
+
export declare function signTrelloPayload(rawBody: string | Buffer, callbackUrl: string, secret: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Wait for an ingestor to reach a specific state.
|
|
50
|
+
* Polls every 250ms until the timeout is reached.
|
|
51
|
+
*/
|
|
52
|
+
export declare function waitForIngestorState(manager: IngestorManager, callerAlias: string, connection: string, targetState: IngestorState, timeoutMs?: number): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Poll until at least one event appears for a connection.
|
|
55
|
+
* Returns the events array once non-empty.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pollUntilEvent(manager: IngestorManager, callerAlias: string, connection: string, timeoutMs?: number): Promise<IngestedEvent[]>;
|
|
58
|
+
/** Common shape every IngestedEvent must satisfy (for use with toMatchObject). */
|
|
59
|
+
export declare const INGESTED_EVENT_SHAPE: {
|
|
60
|
+
id: any;
|
|
61
|
+
idempotencyKey: any;
|
|
62
|
+
receivedAt: any;
|
|
63
|
+
receivedAtMs: any;
|
|
64
|
+
callerAlias: string;
|
|
65
|
+
source: any;
|
|
66
|
+
eventType: any;
|
|
67
|
+
data: any;
|
|
68
|
+
};
|
|
69
|
+
//# sourceMappingURL=setup.d.ts.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared E2E test setup for connection ingestor tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers to:
|
|
5
|
+
* - Load .env.e2e environment variables
|
|
6
|
+
* - Build a RemoteServerConfig with in-memory keys and requested connections
|
|
7
|
+
* - Boot an Express server on a random port
|
|
8
|
+
* - Generate valid webhook signatures (GitHub, Trello)
|
|
9
|
+
* - Wait for ingestor state transitions and event arrival
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import dotenv from 'dotenv';
|
|
14
|
+
import { expect } from 'vitest';
|
|
15
|
+
import { createApp } from '../../server.js';
|
|
16
|
+
import { generateKeyBundle, extractPublicKeys } from '../../../shared/crypto/index.js';
|
|
17
|
+
import { IngestorManager } from '../manager.js';
|
|
18
|
+
// ── Env loading ─────────────────────────────────────────────────────────
|
|
19
|
+
/** Load .env.e2e from the project root (idempotent). */
|
|
20
|
+
let envLoaded = false;
|
|
21
|
+
export function loadE2EEnv() {
|
|
22
|
+
if (envLoaded)
|
|
23
|
+
return;
|
|
24
|
+
dotenv.config({ path: path.resolve(import.meta.dirname, '../../../../.env.e2e') });
|
|
25
|
+
envLoaded = true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check that all required env vars are set.
|
|
29
|
+
* Returns the missing var names (empty array = all present).
|
|
30
|
+
*/
|
|
31
|
+
export function checkEnvVars(vars) {
|
|
32
|
+
loadE2EEnv();
|
|
33
|
+
return vars.filter((v) => !process.env[v]);
|
|
34
|
+
}
|
|
35
|
+
// ── Config builder ──────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Build a RemoteServerConfig for E2E tests.
|
|
38
|
+
*
|
|
39
|
+
* Creates a single caller ("e2e-client") with the requested connections.
|
|
40
|
+
* Env vars are injected into caller.env so secret resolution works against
|
|
41
|
+
* process.env (already loaded by loadE2EEnv).
|
|
42
|
+
*/
|
|
43
|
+
export function buildE2EConfig(connections, opts) {
|
|
44
|
+
return {
|
|
45
|
+
host: '127.0.0.1',
|
|
46
|
+
port: 0,
|
|
47
|
+
callers: {
|
|
48
|
+
'e2e-client': {
|
|
49
|
+
connections,
|
|
50
|
+
env: opts?.env,
|
|
51
|
+
ingestorOverrides: opts?.ingestorOverrides,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
rateLimitPerMinute: 600,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Boot an Express server with the given config on a random port.
|
|
59
|
+
* Starts all ingestors and returns handles for testing.
|
|
60
|
+
*/
|
|
61
|
+
export async function bootServer(config) {
|
|
62
|
+
const serverKeys = generateKeyBundle();
|
|
63
|
+
const clientKeys = generateKeyBundle();
|
|
64
|
+
const clientPub = extractPublicKeys(clientKeys);
|
|
65
|
+
const ingestorManager = new IngestorManager(config);
|
|
66
|
+
const app = createApp({
|
|
67
|
+
config,
|
|
68
|
+
ownKeys: serverKeys,
|
|
69
|
+
authorizedPeers: [{ alias: 'e2e-client', keys: clientPub }],
|
|
70
|
+
ingestorManager,
|
|
71
|
+
disableRateLimiting: true,
|
|
72
|
+
});
|
|
73
|
+
// Start ingestors
|
|
74
|
+
await ingestorManager.startAll();
|
|
75
|
+
// Listen on random port
|
|
76
|
+
const server = await new Promise((resolve) => {
|
|
77
|
+
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
78
|
+
});
|
|
79
|
+
const addr = server.address();
|
|
80
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
81
|
+
return {
|
|
82
|
+
server,
|
|
83
|
+
baseUrl,
|
|
84
|
+
ingestorManager,
|
|
85
|
+
teardown: async () => {
|
|
86
|
+
await ingestorManager.stopAll();
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ── Signature helpers ───────────────────────────────────────────────────
|
|
94
|
+
/** Generate a valid GitHub HMAC-SHA256 signature for a raw body. */
|
|
95
|
+
export function signGitHubPayload(rawBody, secret) {
|
|
96
|
+
const buf = typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody;
|
|
97
|
+
const sig = crypto.createHmac('sha256', secret).update(buf).digest('hex');
|
|
98
|
+
return `sha256=${sig}`;
|
|
99
|
+
}
|
|
100
|
+
/** Generate a valid Trello HMAC-SHA1 base64 signature for body + callbackUrl. */
|
|
101
|
+
export function signTrelloPayload(rawBody, callbackUrl, secret) {
|
|
102
|
+
const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
|
103
|
+
return crypto.createHmac('sha1', secret).update(bodyStr + callbackUrl).digest('base64');
|
|
104
|
+
}
|
|
105
|
+
// ── Polling helpers ─────────────────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Wait for an ingestor to reach a specific state.
|
|
108
|
+
* Polls every 250ms until the timeout is reached.
|
|
109
|
+
*/
|
|
110
|
+
export async function waitForIngestorState(manager, callerAlias, connection, targetState, timeoutMs = 15_000) {
|
|
111
|
+
const start = Date.now();
|
|
112
|
+
while (Date.now() - start < timeoutMs) {
|
|
113
|
+
const statuses = manager.getStatuses(callerAlias);
|
|
114
|
+
const status = statuses.find((s) => s.connection === connection);
|
|
115
|
+
if (status?.state === targetState)
|
|
116
|
+
return;
|
|
117
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`Timed out waiting for ${connection} to reach state "${targetState}" (${timeoutMs}ms)`);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Poll until at least one event appears for a connection.
|
|
123
|
+
* Returns the events array once non-empty.
|
|
124
|
+
*/
|
|
125
|
+
export async function pollUntilEvent(manager, callerAlias, connection, timeoutMs = 10_000) {
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
while (Date.now() - start < timeoutMs) {
|
|
128
|
+
const events = manager.getEvents(callerAlias, connection);
|
|
129
|
+
if (events.length > 0)
|
|
130
|
+
return events;
|
|
131
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Timed out waiting for events from ${connection} (${timeoutMs}ms)`);
|
|
134
|
+
}
|
|
135
|
+
// ── Shared assertions ───────────────────────────────────────────────────
|
|
136
|
+
/** Common shape every IngestedEvent must satisfy (for use with toMatchObject). */
|
|
137
|
+
export const INGESTED_EVENT_SHAPE = {
|
|
138
|
+
id: expect.any(Number),
|
|
139
|
+
idempotencyKey: expect.any(String),
|
|
140
|
+
receivedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
|
141
|
+
receivedAtMs: expect.any(Number),
|
|
142
|
+
callerAlias: 'e2e-client',
|
|
143
|
+
source: expect.any(String),
|
|
144
|
+
eventType: expect.any(String),
|
|
145
|
+
data: expect.anything(),
|
|
146
|
+
};
|
|
147
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -43,6 +43,8 @@ export declare class IngestorManager {
|
|
|
43
43
|
private readonly config;
|
|
44
44
|
/** Active ingestor instances, keyed by `callerAlias:connectionAlias:instanceId`. */
|
|
45
45
|
private ingestors;
|
|
46
|
+
/** Trigger rule engines per caller. Created during startAll() for callers with triggerRules. */
|
|
47
|
+
private triggerEngines;
|
|
46
48
|
/**
|
|
47
49
|
* Optional config loader for hot-reload support. When provided, `startOne()`
|
|
48
50
|
* uses it to get fresh config from disk instead of the constructor snapshot.
|
|
@@ -63,6 +65,12 @@ export declare class IngestorManager {
|
|
|
63
65
|
* Internal: create, register, and start a single ingestor instance.
|
|
64
66
|
*/
|
|
65
67
|
private startIngestor;
|
|
68
|
+
/**
|
|
69
|
+
* Initialize trigger rule engines for callers with triggerRules config.
|
|
70
|
+
* Subscribes to 'event' emissions from matching ingestors and dispatches
|
|
71
|
+
* to Claude Code remote triggers.
|
|
72
|
+
*/
|
|
73
|
+
private initTriggerEngines;
|
|
66
74
|
/**
|
|
67
75
|
* Stop all running ingestors. Called during graceful shutdown.
|
|
68
76
|
*/
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { resolveCallerRoutes, resolveRoutes, resolveSecrets, } from '../../shared/config.js';
|
|
17
17
|
import { createLogger } from '../../shared/logger.js';
|
|
18
|
+
import { TriggerRuleEngine } from '../triggers/rule-engine.js';
|
|
18
19
|
const log = createLogger('ingestor');
|
|
19
20
|
import { createIngestor } from './registry.js';
|
|
20
21
|
import { WebhookIngestor } from './webhook/base-webhook-ingestor.js';
|
|
@@ -48,6 +49,8 @@ export class IngestorManager {
|
|
|
48
49
|
config;
|
|
49
50
|
/** Active ingestor instances, keyed by `callerAlias:connectionAlias:instanceId`. */
|
|
50
51
|
ingestors = new Map();
|
|
52
|
+
/** Trigger rule engines per caller. Created during startAll() for callers with triggerRules. */
|
|
53
|
+
triggerEngines = new Map();
|
|
51
54
|
/**
|
|
52
55
|
* Optional config loader for hot-reload support. When provided, `startOne()`
|
|
53
56
|
* uses it to get fresh config from disk instead of the constructor snapshot.
|
|
@@ -114,6 +117,8 @@ export class IngestorManager {
|
|
|
114
117
|
if (count > 0) {
|
|
115
118
|
log.info(`${count} ingestor(s) started`);
|
|
116
119
|
}
|
|
120
|
+
// Wire up trigger rule engines for callers that have triggerRules
|
|
121
|
+
this.initTriggerEngines();
|
|
117
122
|
}
|
|
118
123
|
/**
|
|
119
124
|
* Internal: create, register, and start a single ingestor instance.
|
|
@@ -146,6 +151,35 @@ export class IngestorManager {
|
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Initialize trigger rule engines for callers with triggerRules config.
|
|
156
|
+
* Subscribes to 'event' emissions from matching ingestors and dispatches
|
|
157
|
+
* to Claude Code remote triggers.
|
|
158
|
+
*/
|
|
159
|
+
initTriggerEngines() {
|
|
160
|
+
const config = this.getConfig();
|
|
161
|
+
for (const [callerAlias, callerConfig] of Object.entries(config.callers)) {
|
|
162
|
+
if (!callerConfig.triggerRules || callerConfig.triggerRules.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
// Resolve caller-level secrets for API key access
|
|
165
|
+
const callerEnvResolved = resolveSecrets(callerConfig.env ?? {});
|
|
166
|
+
const engine = new TriggerRuleEngine(callerConfig.triggerRules, callerEnvResolved);
|
|
167
|
+
if (engine.activeRuleCount === 0)
|
|
168
|
+
continue;
|
|
169
|
+
this.triggerEngines.set(callerAlias, engine);
|
|
170
|
+
// Subscribe the engine to all ingestors belonging to this caller
|
|
171
|
+
for (const [key, ingestor] of this.ingestors) {
|
|
172
|
+
const { caller } = parseKey(key);
|
|
173
|
+
if (caller !== callerAlias)
|
|
174
|
+
continue;
|
|
175
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
|
|
176
|
+
ingestor.on('event', (event) => {
|
|
177
|
+
engine.handleEvent(event);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
log.info(`Trigger rule engine for ${callerAlias}: ${engine.activeRuleCount} active rule(s)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
149
183
|
/**
|
|
150
184
|
* Stop all running ingestors. Called during graceful shutdown.
|
|
151
185
|
*/
|
|
@@ -31,6 +31,8 @@ export declare class PollIngestor extends BaseIngestor {
|
|
|
31
31
|
private readonly responsePath;
|
|
32
32
|
private readonly eventType;
|
|
33
33
|
private readonly pollHeaders;
|
|
34
|
+
private readonly useEtag;
|
|
35
|
+
private lastEtag;
|
|
34
36
|
/** Resolved headers from the parent connection route (injected by manager). */
|
|
35
37
|
private readonly routeHeaders;
|
|
36
38
|
constructor(connectionAlias: string, secrets: Record<string, string>, pollConfig: PollIngestorConfig,
|
|
@@ -42,6 +42,8 @@ export class PollIngestor extends BaseIngestor {
|
|
|
42
42
|
responsePath;
|
|
43
43
|
eventType;
|
|
44
44
|
pollHeaders;
|
|
45
|
+
useEtag;
|
|
46
|
+
lastEtag = null;
|
|
45
47
|
/** Resolved headers from the parent connection route (injected by manager). */
|
|
46
48
|
routeHeaders;
|
|
47
49
|
constructor(connectionAlias, secrets, pollConfig,
|
|
@@ -56,6 +58,7 @@ export class PollIngestor extends BaseIngestor {
|
|
|
56
58
|
this.deduplicateBy = pollConfig.deduplicateBy;
|
|
57
59
|
this.responsePath = pollConfig.responsePath;
|
|
58
60
|
this.eventType = pollConfig.eventType ?? 'poll';
|
|
61
|
+
this.useEtag = pollConfig.etag ?? false;
|
|
59
62
|
this.routeHeaders = routeHeaders;
|
|
60
63
|
// Resolve ${VAR} placeholders in poll-specific headers
|
|
61
64
|
this.pollHeaders = {};
|
|
@@ -95,6 +98,10 @@ export class PollIngestor extends BaseIngestor {
|
|
|
95
98
|
...this.routeHeaders,
|
|
96
99
|
...this.pollHeaders,
|
|
97
100
|
};
|
|
101
|
+
// Add ETag conditional request header if enabled and we have a cached ETag
|
|
102
|
+
if (this.useEtag && this.lastEtag) {
|
|
103
|
+
headers['If-None-Match'] = this.lastEtag;
|
|
104
|
+
}
|
|
98
105
|
// Build request options
|
|
99
106
|
const fetchOptions = {
|
|
100
107
|
method: this.method,
|
|
@@ -115,9 +122,23 @@ export class PollIngestor extends BaseIngestor {
|
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
124
|
const response = await fetch(this.url, fetchOptions);
|
|
125
|
+
// Handle ETag 304 Not Modified — no new data, not an error
|
|
126
|
+
if (this.useEtag && response.status === 304) {
|
|
127
|
+
this.consecutiveErrors = 0;
|
|
128
|
+
if (this.state !== 'connected')
|
|
129
|
+
this.state = 'connected';
|
|
130
|
+
log.debug(`${this.connectionAlias}: 304 Not Modified (ETag cache hit)`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
118
133
|
if (!response.ok) {
|
|
119
134
|
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
120
135
|
}
|
|
136
|
+
// Store ETag for subsequent conditional requests
|
|
137
|
+
if (this.useEtag) {
|
|
138
|
+
const etag = response.headers.get('etag');
|
|
139
|
+
if (etag)
|
|
140
|
+
this.lastEtag = etag;
|
|
141
|
+
}
|
|
121
142
|
const responseBody = await response.json();
|
|
122
143
|
// Extract items array from response using responsePath
|
|
123
144
|
const items = this.extractItems(responseBody);
|
|
@@ -86,6 +86,12 @@ export interface PollIngestorConfig {
|
|
|
86
86
|
* Values may contain ${VAR} placeholders.
|
|
87
87
|
* These are merged UNDER the connection's route headers (route headers take precedence). */
|
|
88
88
|
headers?: Record<string, string>;
|
|
89
|
+
/** Enable ETag-based conditional requests.
|
|
90
|
+
* When true, the poller stores the ETag from each successful response and sends
|
|
91
|
+
* `If-None-Match` on subsequent requests. HTTP 304 responses are treated as a
|
|
92
|
+
* successful no-op (no items, no error). Useful for APIs like GitHub Events
|
|
93
|
+
* where 304s do not count against rate limits. */
|
|
94
|
+
etag?: boolean;
|
|
89
95
|
}
|
|
90
96
|
/** A single event received by an ingestor, stored in the ring buffer. */
|
|
91
97
|
export interface IngestedEvent {
|
|
@@ -20,6 +20,11 @@ export declare class GitHubWebhookIngestor extends WebhookIngestor {
|
|
|
20
20
|
*/
|
|
21
21
|
private readonly repoFilter;
|
|
22
22
|
constructor(connectionAlias: string, secrets: Record<string, string>, webhookConfig: WebhookIngestorConfig, bufferSize?: number, instanceId?: string);
|
|
23
|
+
/**
|
|
24
|
+
* Return the repository name for multi-instance webhook lifecycle management.
|
|
25
|
+
* Enables the lifecycle manager to match and clean up stale webhooks per-repo.
|
|
26
|
+
*/
|
|
27
|
+
protected getModelId(): string | undefined;
|
|
23
28
|
/**
|
|
24
29
|
* Filter webhooks by repository for multi-instance support.
|
|
25
30
|
* When repoFilter is set, only events from those repos are accepted.
|
|
@@ -29,6 +29,13 @@ export class GitHubWebhookIngestor extends WebhookIngestor {
|
|
|
29
29
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- injected by IngestorManager for multi-instance support
|
|
30
30
|
this.repoFilter = webhookConfig._repoFilter ?? [];
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Return the repository name for multi-instance webhook lifecycle management.
|
|
34
|
+
* Enables the lifecycle manager to match and clean up stale webhooks per-repo.
|
|
35
|
+
*/
|
|
36
|
+
getModelId() {
|
|
37
|
+
return this.repoFilter.length === 1 ? this.repoFilter[0] : undefined;
|
|
38
|
+
}
|
|
32
39
|
/**
|
|
33
40
|
* Filter webhooks by repository for multi-instance support.
|
|
34
41
|
* When repoFilter is set, only events from those repos are accepted.
|
|
@@ -88,33 +88,33 @@ export class WebhookLifecycleManager {
|
|
|
88
88
|
const listConfig = this.config.list;
|
|
89
89
|
// Find a webhook matching our callback URL (and model ID if applicable)
|
|
90
90
|
const matching = existingWebhooks.find((wh) => {
|
|
91
|
-
const rawUrl = wh
|
|
91
|
+
const rawUrl = getByPath(wh, listConfig.callbackUrlField);
|
|
92
92
|
const whCallbackUrl = typeof rawUrl === 'string' ? rawUrl : '';
|
|
93
93
|
const urlMatch = whCallbackUrl === callbackUrl;
|
|
94
94
|
if (!urlMatch)
|
|
95
95
|
return false;
|
|
96
96
|
if (modelId && listConfig.modelIdField) {
|
|
97
|
-
const rawModelId = wh
|
|
97
|
+
const rawModelId = getByPath(wh, listConfig.modelIdField);
|
|
98
98
|
return (typeof rawModelId === 'string' ? rawModelId : '') === modelId;
|
|
99
99
|
}
|
|
100
100
|
return true;
|
|
101
101
|
});
|
|
102
102
|
if (matching) {
|
|
103
|
-
const webhookId = String(matching
|
|
103
|
+
const webhookId = String(getByPath(matching, listConfig.idField));
|
|
104
104
|
log.info(`Found existing webhook (ID: ${webhookId}), reusing`);
|
|
105
105
|
return { registered: true, webhookId, lastAttempt: now };
|
|
106
106
|
}
|
|
107
107
|
// Clean up stale webhooks (matching model but wrong callback URL)
|
|
108
108
|
if (modelId && listConfig.modelIdField && this.config.unregister) {
|
|
109
109
|
const stale = existingWebhooks.filter((wh) => {
|
|
110
|
-
const rawModelId = wh
|
|
110
|
+
const rawModelId = getByPath(wh, listConfig.modelIdField);
|
|
111
111
|
const whModelId = typeof rawModelId === 'string' ? rawModelId : '';
|
|
112
|
-
const rawUrl = wh
|
|
112
|
+
const rawUrl = getByPath(wh, listConfig.callbackUrlField);
|
|
113
113
|
const whCallbackUrl = typeof rawUrl === 'string' ? rawUrl : '';
|
|
114
114
|
return whModelId === modelId && whCallbackUrl !== callbackUrl;
|
|
115
115
|
});
|
|
116
116
|
for (const staleWh of stale) {
|
|
117
|
-
const staleId = String(staleWh
|
|
117
|
+
const staleId = String(getByPath(staleWh, listConfig.idField));
|
|
118
118
|
log.info(`Cleaning up stale webhook (ID: ${staleId})`);
|
|
119
119
|
try {
|
|
120
120
|
await this.unregister(staleId);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger Rule Engine.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to ingestor `'event'` emissions and evaluates trigger rules
|
|
5
|
+
* against each event. When a rule matches, dispatches the event to the
|
|
6
|
+
* configured Claude Code remote trigger.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Source + event type + dot-path filter matching
|
|
10
|
+
* - Token-bucket rate limiting per rule
|
|
11
|
+
* - Deduplication within the throttle window
|
|
12
|
+
* - Graceful error handling (dispatch failures never crash ingestors)
|
|
13
|
+
* - Audit logging for all dispatch attempts
|
|
14
|
+
*/
|
|
15
|
+
import type { IngestedEvent } from '../ingestors/types.js';
|
|
16
|
+
import type { TriggerRule, TriggerDispatchResult } from './types.js';
|
|
17
|
+
export declare class TriggerRuleEngine {
|
|
18
|
+
private readonly rules;
|
|
19
|
+
private readonly secrets;
|
|
20
|
+
private readonly ruleStates;
|
|
21
|
+
/** Recent dispatch results for diagnostics. */
|
|
22
|
+
private readonly dispatchLog;
|
|
23
|
+
private static readonly MAX_DISPATCH_LOG;
|
|
24
|
+
constructor(rules: TriggerRule[], secrets: Record<string, string>);
|
|
25
|
+
/** Handle an ingestor event — evaluate all rules and dispatch matches. */
|
|
26
|
+
handleEvent(event: IngestedEvent): void;
|
|
27
|
+
/** Get recent dispatch results for diagnostics. */
|
|
28
|
+
getDispatchLog(): readonly TriggerDispatchResult[];
|
|
29
|
+
/** Get the number of active rules. */
|
|
30
|
+
get activeRuleCount(): number;
|
|
31
|
+
/** Check if an event matches a rule's criteria. */
|
|
32
|
+
private matches;
|
|
33
|
+
/** Check and update throttle state. Returns true if the dispatch should proceed. */
|
|
34
|
+
private checkThrottle;
|
|
35
|
+
/** Dispatch an event to the rule's target. Never throws. */
|
|
36
|
+
private dispatch;
|
|
37
|
+
/** Record a dispatch result in the audit log. */
|
|
38
|
+
private logDispatch;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=rule-engine.d.ts.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger Rule Engine.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to ingestor `'event'` emissions and evaluates trigger rules
|
|
5
|
+
* against each event. When a rule matches, dispatches the event to the
|
|
6
|
+
* configured Claude Code remote trigger.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Source + event type + dot-path filter matching
|
|
10
|
+
* - Token-bucket rate limiting per rule
|
|
11
|
+
* - Deduplication within the throttle window
|
|
12
|
+
* - Graceful error handling (dispatch failures never crash ingestors)
|
|
13
|
+
* - Audit logging for all dispatch attempts
|
|
14
|
+
*/
|
|
15
|
+
import { createLogger } from '../../shared/logger.js';
|
|
16
|
+
const log = createLogger('trigger-engine');
|
|
17
|
+
/** Default maximum dispatches per minute when no throttle is configured. */
|
|
18
|
+
const DEFAULT_MAX_PER_MINUTE = 10;
|
|
19
|
+
/** Duration of the throttle window in milliseconds. */
|
|
20
|
+
const THROTTLE_WINDOW_MS = 60_000;
|
|
21
|
+
/** Maximum dedup keys to track per rule (prevents memory leaks). */
|
|
22
|
+
const MAX_DEDUP_KEYS = 1_000;
|
|
23
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a dot-separated path on an object.
|
|
26
|
+
* E.g., `getByPath(obj, 'payload.action')` → `obj.payload.action`.
|
|
27
|
+
*/
|
|
28
|
+
function getByPath(obj, path) {
|
|
29
|
+
let current = obj;
|
|
30
|
+
for (const segment of path.split('.')) {
|
|
31
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
current = current[segment];
|
|
35
|
+
}
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
// ── Rule Engine ──────────────────────────────────────────────────────────
|
|
39
|
+
export class TriggerRuleEngine {
|
|
40
|
+
rules;
|
|
41
|
+
secrets;
|
|
42
|
+
ruleStates = new Map();
|
|
43
|
+
/** Recent dispatch results for diagnostics. */
|
|
44
|
+
dispatchLog = [];
|
|
45
|
+
static MAX_DISPATCH_LOG = 100;
|
|
46
|
+
constructor(rules, secrets) {
|
|
47
|
+
this.rules = rules.filter((r) => r.enabled !== false);
|
|
48
|
+
this.secrets = secrets;
|
|
49
|
+
// Initialize per-rule state
|
|
50
|
+
for (const rule of this.rules) {
|
|
51
|
+
this.ruleStates.set(rule.name, {
|
|
52
|
+
dispatchTimestamps: [],
|
|
53
|
+
recentDedupKeys: new Set(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (this.rules.length > 0) {
|
|
57
|
+
log.info(`Trigger rule engine initialized with ${this.rules.length} active rule(s)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Handle an ingestor event — evaluate all rules and dispatch matches. */
|
|
61
|
+
handleEvent(event) {
|
|
62
|
+
for (const rule of this.rules) {
|
|
63
|
+
if (this.matches(rule, event)) {
|
|
64
|
+
void this.dispatch(rule, event);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Get recent dispatch results for diagnostics. */
|
|
69
|
+
getDispatchLog() {
|
|
70
|
+
return this.dispatchLog;
|
|
71
|
+
}
|
|
72
|
+
/** Get the number of active rules. */
|
|
73
|
+
get activeRuleCount() {
|
|
74
|
+
return this.rules.length;
|
|
75
|
+
}
|
|
76
|
+
// ── Rule matching ──────────────────────────────────────────────────────
|
|
77
|
+
/** Check if an event matches a rule's criteria. */
|
|
78
|
+
matches(rule, event) {
|
|
79
|
+
// Source must match
|
|
80
|
+
if (rule.source !== event.source)
|
|
81
|
+
return false;
|
|
82
|
+
// Instance ID filter (if specified)
|
|
83
|
+
if (rule.instanceId !== undefined && rule.instanceId !== event.instanceId)
|
|
84
|
+
return false;
|
|
85
|
+
// Event type filter (empty = match all)
|
|
86
|
+
if (rule.eventTypes && rule.eventTypes.length > 0) {
|
|
87
|
+
if (!rule.eventTypes.includes(event.eventType))
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// Dot-path filter predicates (AND logic)
|
|
91
|
+
if (rule.filter) {
|
|
92
|
+
for (const [path, acceptedValues] of Object.entries(rule.filter)) {
|
|
93
|
+
const actualValue = getByPath(event.data, path);
|
|
94
|
+
if (!Array.isArray(acceptedValues))
|
|
95
|
+
continue;
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
if (!acceptedValues.includes(actualValue))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// ── Throttle & dedup ───────────────────────────────────────────────────
|
|
104
|
+
/** Check and update throttle state. Returns true if the dispatch should proceed. */
|
|
105
|
+
checkThrottle(rule, event) {
|
|
106
|
+
const state = this.ruleStates.get(rule.name);
|
|
107
|
+
if (!state)
|
|
108
|
+
return false;
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const maxPerMinute = rule.throttle?.maxPerMinute ?? DEFAULT_MAX_PER_MINUTE;
|
|
111
|
+
// Prune timestamps outside the window
|
|
112
|
+
state.dispatchTimestamps = state.dispatchTimestamps.filter((ts) => now - ts < THROTTLE_WINDOW_MS);
|
|
113
|
+
// Rate limit check
|
|
114
|
+
if (state.dispatchTimestamps.length >= maxPerMinute) {
|
|
115
|
+
log.warn(`${rule.name}: throttled (${maxPerMinute}/min limit reached)`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
// Dedup check
|
|
119
|
+
if (rule.throttle?.deduplicateBy) {
|
|
120
|
+
const dedupValue = getByPath(event.data, rule.throttle.deduplicateBy);
|
|
121
|
+
if (dedupValue !== undefined && dedupValue !== null) {
|
|
122
|
+
const dedupKey = typeof dedupValue === 'string' ? dedupValue : JSON.stringify(dedupValue);
|
|
123
|
+
if (state.recentDedupKeys.has(dedupKey)) {
|
|
124
|
+
log.debug(`${rule.name}: deduplicated (key: ${dedupKey})`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
state.recentDedupKeys.add(dedupKey);
|
|
128
|
+
if (state.recentDedupKeys.size > MAX_DEDUP_KEYS) {
|
|
129
|
+
// Prune oldest half
|
|
130
|
+
const pruneCount = Math.floor(state.recentDedupKeys.size / 2);
|
|
131
|
+
let removed = 0;
|
|
132
|
+
for (const key of state.recentDedupKeys) {
|
|
133
|
+
if (removed >= pruneCount)
|
|
134
|
+
break;
|
|
135
|
+
state.recentDedupKeys.delete(key);
|
|
136
|
+
removed++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Record this dispatch
|
|
142
|
+
state.dispatchTimestamps.push(now);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// ── Dispatch ───────────────────────────────────────────────────────────
|
|
146
|
+
/** Dispatch an event to the rule's target. Never throws. */
|
|
147
|
+
async dispatch(rule, event) {
|
|
148
|
+
// Check throttle/dedup before dispatching
|
|
149
|
+
if (!this.checkThrottle(rule, event))
|
|
150
|
+
return;
|
|
151
|
+
const { triggerId } = rule.target;
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
try {
|
|
154
|
+
const apiKey = this.secrets.ANTHROPIC_API_KEY;
|
|
155
|
+
if (!apiKey) {
|
|
156
|
+
this.logDispatch({
|
|
157
|
+
rule: rule.name,
|
|
158
|
+
success: false,
|
|
159
|
+
triggerId,
|
|
160
|
+
error: 'ANTHROPIC_API_KEY not configured in caller secrets',
|
|
161
|
+
dispatchedAt: now,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const url = `https://api.anthropic.com/v1/code/triggers/${triggerId}/run`;
|
|
166
|
+
const body = JSON.stringify({
|
|
167
|
+
event: {
|
|
168
|
+
source: event.source,
|
|
169
|
+
instanceId: event.instanceId,
|
|
170
|
+
eventType: event.eventType,
|
|
171
|
+
receivedAt: event.receivedAt,
|
|
172
|
+
data: event.data,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
log.info(`${rule.name}: dispatching to trigger ${triggerId} (event: ${event.eventType})`);
|
|
176
|
+
const response = await fetch(url, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: {
|
|
179
|
+
Authorization: `Bearer ${apiKey}`,
|
|
180
|
+
'Content-Type': 'application/json',
|
|
181
|
+
'anthropic-version': '2023-06-01',
|
|
182
|
+
},
|
|
183
|
+
body,
|
|
184
|
+
});
|
|
185
|
+
if (response.ok) {
|
|
186
|
+
this.logDispatch({
|
|
187
|
+
rule: rule.name,
|
|
188
|
+
success: true,
|
|
189
|
+
triggerId,
|
|
190
|
+
statusCode: response.status,
|
|
191
|
+
dispatchedAt: now,
|
|
192
|
+
});
|
|
193
|
+
log.info(`${rule.name}: trigger ${triggerId} invoked successfully`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const errorBody = await response.text().catch(() => '');
|
|
197
|
+
this.logDispatch({
|
|
198
|
+
rule: rule.name,
|
|
199
|
+
success: false,
|
|
200
|
+
triggerId,
|
|
201
|
+
statusCode: response.status,
|
|
202
|
+
error: `HTTP ${response.status}: ${errorBody.slice(0, 200)}`,
|
|
203
|
+
dispatchedAt: now,
|
|
204
|
+
});
|
|
205
|
+
log.warn(`${rule.name}: trigger dispatch failed (${response.status}): ${errorBody.slice(0, 200)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
210
|
+
this.logDispatch({
|
|
211
|
+
rule: rule.name,
|
|
212
|
+
success: false,
|
|
213
|
+
triggerId,
|
|
214
|
+
error,
|
|
215
|
+
dispatchedAt: now,
|
|
216
|
+
});
|
|
217
|
+
log.error(`${rule.name}: trigger dispatch error: ${error}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Record a dispatch result in the audit log. */
|
|
221
|
+
logDispatch(result) {
|
|
222
|
+
this.dispatchLog.push(result);
|
|
223
|
+
if (this.dispatchLog.length > TriggerRuleEngine.MAX_DISPATCH_LOG) {
|
|
224
|
+
this.dispatchLog.splice(0, this.dispatchLog.length - TriggerRuleEngine.MAX_DISPATCH_LOG);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=rule-engine.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger rule types for the event-to-agent bridge.
|
|
3
|
+
*
|
|
4
|
+
* Trigger rules define how ingestor events are dispatched to Claude Code
|
|
5
|
+
* remote triggers. Each rule matches events by source, event type, and
|
|
6
|
+
* optional filter predicates, then invokes a remote trigger with the
|
|
7
|
+
* event payload.
|
|
8
|
+
*/
|
|
9
|
+
/** A single trigger rule that maps ingestor events to a remote trigger invocation. */
|
|
10
|
+
export interface TriggerRule {
|
|
11
|
+
/** Human-readable name for logging and diagnostics. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Connection alias to match events from (e.g., "github", "discord-bot"). */
|
|
14
|
+
source: string;
|
|
15
|
+
/** Optional instance ID filter (for multi-instance listeners). */
|
|
16
|
+
instanceId?: string;
|
|
17
|
+
/** Event types to match. Empty or omitted = match all event types. */
|
|
18
|
+
eventTypes?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Dot-path filter predicates applied to the event data.
|
|
21
|
+
*
|
|
22
|
+
* Keys are dot-separated paths into `event.data` (e.g., "payload.action").
|
|
23
|
+
* Values are arrays of acceptable values — the event matches if the resolved
|
|
24
|
+
* value equals any entry in the array.
|
|
25
|
+
*
|
|
26
|
+
* All predicates must match for the rule to fire (AND logic).
|
|
27
|
+
*/
|
|
28
|
+
filter?: Record<string, unknown[]>;
|
|
29
|
+
/** Target to invoke when the rule matches. */
|
|
30
|
+
target: TriggerTarget;
|
|
31
|
+
/** Rate limiting and deduplication. */
|
|
32
|
+
throttle?: TriggerThrottle;
|
|
33
|
+
/** Whether this rule is active. Default: true. */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Target for a trigger rule — currently only remote triggers are supported. */
|
|
37
|
+
export interface TriggerTarget {
|
|
38
|
+
/** Target type. */
|
|
39
|
+
type: 'remote_trigger';
|
|
40
|
+
/** Claude Code RemoteTrigger ID (e.g., "trg_abc123"). */
|
|
41
|
+
triggerId: string;
|
|
42
|
+
}
|
|
43
|
+
/** Rate limiting and deduplication for trigger dispatch. */
|
|
44
|
+
export interface TriggerThrottle {
|
|
45
|
+
/** Maximum dispatches per minute for this rule. Default: 10. */
|
|
46
|
+
maxPerMinute?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Dot-path into event data to use as a deduplication key.
|
|
49
|
+
* E.g., "payload.pull_request.number" — prevents the same PR from
|
|
50
|
+
* triggering multiple times within the throttle window.
|
|
51
|
+
*/
|
|
52
|
+
deduplicateBy?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Result of a single trigger dispatch attempt. */
|
|
55
|
+
export interface TriggerDispatchResult {
|
|
56
|
+
/** Rule name that fired. */
|
|
57
|
+
rule: string;
|
|
58
|
+
/** Whether the dispatch succeeded. */
|
|
59
|
+
success: boolean;
|
|
60
|
+
/** Trigger ID that was invoked. */
|
|
61
|
+
triggerId: string;
|
|
62
|
+
/** HTTP status code from the remote trigger API. */
|
|
63
|
+
statusCode?: number;
|
|
64
|
+
/** Error message if dispatch failed. */
|
|
65
|
+
error?: string;
|
|
66
|
+
/** ISO-8601 timestamp of the dispatch. */
|
|
67
|
+
dispatchedAt: string;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger rule types for the event-to-agent bridge.
|
|
3
|
+
*
|
|
4
|
+
* Trigger rules define how ingestor events are dispatched to Claude Code
|
|
5
|
+
* remote triggers. Each rule matches events by source, event type, and
|
|
6
|
+
* optional filter predicates, then invokes a remote trigger with the
|
|
7
|
+
* event payload.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Keys directory: ~/.drawlatch/keys/
|
|
12
12
|
*/
|
|
13
13
|
import type { IngestorConfig } from '../remote/ingestors/types.js';
|
|
14
|
+
import type { TriggerRule } from '../remote/triggers/types.js';
|
|
14
15
|
import type { TestConnectionConfig, TestIngestorConfig, ListenerConfigSchema } from './listener-config.js';
|
|
15
16
|
export type { TestConnectionConfig, TestIngestorConfig, ListenerConfigSchema, } from './listener-config.js';
|
|
16
17
|
export type { ListenerConfigField, ListenerConfigOption } from './listener-config.js';
|
|
@@ -182,6 +183,10 @@ export interface CallerConfig {
|
|
|
182
183
|
* }
|
|
183
184
|
* ``` */
|
|
184
185
|
listenerInstances?: Record<string, Record<string, IngestorOverrides>>;
|
|
186
|
+
/** Trigger rules that map ingestor events to Claude Code remote trigger invocations.
|
|
187
|
+
* When an ingestor event matches a rule's criteria (source, event type, filter),
|
|
188
|
+
* the engine dispatches the event to the configured remote trigger. */
|
|
189
|
+
triggerRules?: TriggerRule[];
|
|
185
190
|
}
|
|
186
191
|
/** Remote server configuration */
|
|
187
192
|
export interface RemoteServerConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wolpertingerlabs/drawlatch",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.12.0",
|
|
4
4
|
"description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/mcp/server.js",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"start:remote": "NODE_ENV=production node dist/remote/server.js",
|
|
70
70
|
"start:mcp": "node dist/mcp/server.js",
|
|
71
71
|
"test": "vitest run",
|
|
72
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
72
73
|
"test:watch": "vitest",
|
|
73
74
|
"test:coverage": "vitest run --coverage",
|
|
74
75
|
"lint": "eslint src/",
|