@agrentingai/paperclip-adapter 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/README.md +306 -0
- package/dist/server/index.cjs +1406 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +895 -0
- package/dist/server/index.d.ts +895 -0
- package/dist/server/index.js +1336 -0
- package/dist/server/index.js.map +1 -0
- package/dist/ui/index.cjs +125 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +36 -0
- package/dist/ui/index.d.ts +36 -0
- package/dist/ui/index.js +98 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +65 -0
- package/server/src/adapter.test.ts +497 -0
- package/server/src/adapter.ts +1044 -0
- package/server/src/balance-monitor.test.ts +147 -0
- package/server/src/balance-monitor.ts +118 -0
- package/server/src/client.test.ts +949 -0
- package/server/src/client.ts +550 -0
- package/server/src/comment-sync.test.ts +25 -0
- package/server/src/comment-sync.ts +71 -0
- package/server/src/crypto.test.ts +62 -0
- package/server/src/crypto.ts +25 -0
- package/server/src/index.ts +67 -0
- package/server/src/polling.test.ts +208 -0
- package/server/src/polling.ts +183 -0
- package/server/src/types.ts +244 -0
- package/server/src/webhook-handler.test.ts +379 -0
- package/server/src/webhook-handler.ts +292 -0
- package/ui/src/adapter.ts +131 -0
- package/ui/src/index.ts +2 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paperclip-side webhook handler for Agrenting task events.
|
|
3
|
+
*
|
|
4
|
+
* This module provides:
|
|
5
|
+
* - An HTTP request handler that processes incoming webhook payloads from Agrenting
|
|
6
|
+
* - Task ID to Paperclip issue ID mapping registry
|
|
7
|
+
* - Automatic issue status updates and comment posting on task events
|
|
8
|
+
*
|
|
9
|
+
* Usage: Paperclip mounts this handler at `POST /api/webhooks/agrenting/:companyId`.
|
|
10
|
+
* The handler receives raw body, headers, and a Paperclip API client to update issues.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IncomingHttpHeaders } from "http";
|
|
14
|
+
import type { AgrentingAdapterConfig } from "./types.js";
|
|
15
|
+
import { verifyWebhookSignature } from "./crypto.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Task → Issue mapping registry
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
interface TaskMapping {
|
|
22
|
+
issueId: string;
|
|
23
|
+
companyId: string;
|
|
24
|
+
config: AgrentingAdapterConfig;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
status: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const taskRegistry = new Map<string, TaskMapping>();
|
|
30
|
+
|
|
31
|
+
const TASK_REGISTRY_TTL_MS = 2 * 60 * 60 * 1000; // 2h — max age before cleanup
|
|
32
|
+
const TASK_REGISTRY_CLEANUP_INTERVAL_MS = 60_000; // 60s sweep interval
|
|
33
|
+
let registryCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sweep taskRegistry for entries older than the TTL.
|
|
37
|
+
* Called periodically to bound memory growth if terminal events are lost.
|
|
38
|
+
*/
|
|
39
|
+
function sweepStaleRegistryEntries(): void {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [id, entry] of taskRegistry) {
|
|
42
|
+
if (now - entry.startedAt > TASK_REGISTRY_TTL_MS) {
|
|
43
|
+
taskRegistry.delete(id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start the periodic registry cleanup timer.
|
|
50
|
+
* Safe to call multiple times — only one timer runs at a time.
|
|
51
|
+
*/
|
|
52
|
+
export function startRegistryCleanup(): void {
|
|
53
|
+
if (registryCleanupTimer) return;
|
|
54
|
+
registryCleanupTimer = setInterval(sweepStaleRegistryEntries, TASK_REGISTRY_CLEANUP_INTERVAL_MS);
|
|
55
|
+
registryCleanupTimer.unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stop the periodic registry cleanup timer.
|
|
60
|
+
*/
|
|
61
|
+
export function stopRegistryCleanup(): void {
|
|
62
|
+
if (registryCleanupTimer) {
|
|
63
|
+
clearInterval(registryCleanupTimer);
|
|
64
|
+
registryCleanupTimer = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register a mapping between an Agrenting task ID and a Paperclip issue.
|
|
70
|
+
* Call this before executing a task so the webhook handler knows which issue to update.
|
|
71
|
+
*/
|
|
72
|
+
export function registerTaskMapping(
|
|
73
|
+
taskId: string,
|
|
74
|
+
issueId: string,
|
|
75
|
+
companyId: string,
|
|
76
|
+
config: AgrentingAdapterConfig
|
|
77
|
+
): void {
|
|
78
|
+
taskRegistry.set(taskId, {
|
|
79
|
+
issueId,
|
|
80
|
+
companyId,
|
|
81
|
+
config,
|
|
82
|
+
startedAt: Date.now(),
|
|
83
|
+
status: "pending",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove a task mapping from the registry.
|
|
89
|
+
*/
|
|
90
|
+
export function unregisterTaskMapping(taskId: string): void {
|
|
91
|
+
taskRegistry.delete(taskId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get all active task mappings.
|
|
96
|
+
*/
|
|
97
|
+
export function getActiveTaskMappings(): ReadonlyMap<string, TaskMapping> {
|
|
98
|
+
return taskRegistry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Webhook payload types
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface AgrentingWebhookPayload {
|
|
106
|
+
task_id: string;
|
|
107
|
+
status: string;
|
|
108
|
+
output?: string;
|
|
109
|
+
error_reason?: string;
|
|
110
|
+
progress_percent?: number;
|
|
111
|
+
progress_message?: string;
|
|
112
|
+
completed_at?: string;
|
|
113
|
+
created_at?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Paperclip API client interface
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface PaperclipApiClient {
|
|
121
|
+
/** Update an issue's status and optionally post a comment */
|
|
122
|
+
updateIssue(issueId: string, body: {
|
|
123
|
+
status?: string;
|
|
124
|
+
comment?: string;
|
|
125
|
+
}): Promise<void>;
|
|
126
|
+
|
|
127
|
+
/** Post a comment on an issue without changing status */
|
|
128
|
+
postComment(issueId: string, body: string): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Event handlers
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
interface EventHandlerContext {
|
|
136
|
+
api: PaperclipApiClient;
|
|
137
|
+
mapping: TaskMapping;
|
|
138
|
+
payload: AgrentingWebhookPayload;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const eventHandlers: Record<
|
|
142
|
+
string,
|
|
143
|
+
(ctx: EventHandlerContext) => Promise<void>
|
|
144
|
+
> = {
|
|
145
|
+
"task.created": async (ctx) => {
|
|
146
|
+
ctx.mapping.status = "pending";
|
|
147
|
+
await ctx.api.postComment(
|
|
148
|
+
ctx.mapping.issueId,
|
|
149
|
+
`**Agrenting task created** — Task \`${ctx.payload.task_id}\` submitted and awaiting claim.`
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
"task.claimed": async (ctx) => {
|
|
154
|
+
ctx.mapping.status = "claimed";
|
|
155
|
+
await ctx.api.postComment(
|
|
156
|
+
ctx.mapping.issueId,
|
|
157
|
+
`**Agrenting task claimed** — An agent has picked up the task and is preparing to work.`
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
"task.in_progress": async (ctx) => {
|
|
162
|
+
ctx.mapping.status = "in_progress";
|
|
163
|
+
const progress = ctx.payload.progress_percent
|
|
164
|
+
? ` (${ctx.payload.progress_percent}%)`
|
|
165
|
+
: "";
|
|
166
|
+
const message = ctx.payload.progress_message
|
|
167
|
+
? ` — ${ctx.payload.progress_message}`
|
|
168
|
+
: "";
|
|
169
|
+
await ctx.api.postComment(
|
|
170
|
+
ctx.mapping.issueId,
|
|
171
|
+
`**Task in progress**${progress}${message}`
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
"task.completed": async (ctx) => {
|
|
176
|
+
ctx.mapping.status = "completed";
|
|
177
|
+
const duration = ((Date.now() - ctx.mapping.startedAt) / 1000).toFixed(1);
|
|
178
|
+
await ctx.api.updateIssue(ctx.mapping.issueId, {
|
|
179
|
+
status: "done",
|
|
180
|
+
comment: `**Agrenting task completed** — Task \`${ctx.payload.task_id}\` finished in ${duration}s.${
|
|
181
|
+
ctx.payload.output ? `\n\n### Output\n\n${ctx.payload.output}` : ""
|
|
182
|
+
}`,
|
|
183
|
+
});
|
|
184
|
+
unregisterTaskMapping(ctx.payload.task_id);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
"task.failed": async (ctx) => {
|
|
188
|
+
ctx.mapping.status = "failed";
|
|
189
|
+
await ctx.api.updateIssue(ctx.mapping.issueId, {
|
|
190
|
+
status: "blocked",
|
|
191
|
+
comment: `**Agrenting task failed** — Task \`${ctx.payload.task_id}\` encountered an error.\n\n**Error:** ${ctx.payload.error_reason ?? "No error reason provided."}`,
|
|
192
|
+
});
|
|
193
|
+
unregisterTaskMapping(ctx.payload.task_id);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
"task.cancelled": async (ctx) => {
|
|
197
|
+
ctx.mapping.status = "cancelled";
|
|
198
|
+
await ctx.api.updateIssue(ctx.mapping.issueId, {
|
|
199
|
+
status: "cancelled",
|
|
200
|
+
comment: `**Agrenting task cancelled** — Task \`${ctx.payload.task_id}\` was cancelled.`,
|
|
201
|
+
});
|
|
202
|
+
unregisterTaskMapping(ctx.payload.task_id);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Webhook handler factory
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
export interface WebhookHandlerOptions {
|
|
211
|
+
/** Secret used to verify HMAC signatures */
|
|
212
|
+
webhookSecret: string;
|
|
213
|
+
/** Paperclip API client for updating issues */
|
|
214
|
+
api: PaperclipApiClient;
|
|
215
|
+
/** Optional: handle unknown event types */
|
|
216
|
+
onUnknownEvent?: (event: string, payload: AgrentingWebhookPayload) => void;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create a webhook handler function suitable for mounting in an HTTP server.
|
|
221
|
+
*
|
|
222
|
+
* The returned handler expects `(rawBody, headers)` and returns a Promise
|
|
223
|
+
* resolving to `{ status, body, headers }` for the HTTP response.
|
|
224
|
+
*
|
|
225
|
+
* This design is framework-agnostic: Paperclip can wrap it in Express,
|
|
226
|
+
* Fastify, or a raw Node.js http server.
|
|
227
|
+
*/
|
|
228
|
+
export function createWebhookHandler(options: WebhookHandlerOptions) {
|
|
229
|
+
startRegistryCleanup();
|
|
230
|
+
return async function handleWebhookRequest(
|
|
231
|
+
rawBody: string,
|
|
232
|
+
headers: IncomingHttpHeaders
|
|
233
|
+
): Promise<{ status: number; body: string; headers?: Record<string, string> }> {
|
|
234
|
+
const signature =
|
|
235
|
+
(headers["x-webhook-signature"] as string) ??
|
|
236
|
+
(headers["X-Webhook-Signature"] as string) ??
|
|
237
|
+
"";
|
|
238
|
+
const eventType =
|
|
239
|
+
(headers["x-webhook-event"] as string) ??
|
|
240
|
+
(headers["X-Webhook-Event"] as string) ??
|
|
241
|
+
"";
|
|
242
|
+
|
|
243
|
+
// Parse payload
|
|
244
|
+
let payload: AgrentingWebhookPayload;
|
|
245
|
+
try {
|
|
246
|
+
payload = JSON.parse(rawBody);
|
|
247
|
+
} catch {
|
|
248
|
+
return { status: 400, body: "Invalid JSON" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Verify signature
|
|
252
|
+
const valid = await verifyWebhookSignature(
|
|
253
|
+
rawBody,
|
|
254
|
+
signature,
|
|
255
|
+
options.webhookSecret
|
|
256
|
+
);
|
|
257
|
+
if (!valid) {
|
|
258
|
+
return { status: 401, body: "Invalid signature" };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Look up task mapping
|
|
262
|
+
const taskId = payload.task_id;
|
|
263
|
+
if (!taskId) {
|
|
264
|
+
return { status: 400, body: "Missing task_id in payload" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mapping = taskRegistry.get(taskId);
|
|
268
|
+
if (!mapping) {
|
|
269
|
+
// Webhook received but no active mapping — task may have been handled
|
|
270
|
+
// by the in-process listener already, or the mapping was cleaned up.
|
|
271
|
+
return { status: 200, body: "OK (no active mapping)" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Dispatch to event handler
|
|
275
|
+
const handler = eventHandlers[eventType];
|
|
276
|
+
if (handler) {
|
|
277
|
+
try {
|
|
278
|
+
await handler({ api: options.api, mapping, payload });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error("[adapter-agrenting] Webhook handler error:", err);
|
|
281
|
+
return {
|
|
282
|
+
status: 500,
|
|
283
|
+
body: "Internal error",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
options.onUnknownEvent?.(eventType, payload);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { status: 200, body: "OK" };
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI adapter for the Agrenting Paperclip adapter.
|
|
3
|
+
*
|
|
4
|
+
* The UI adapter provides:
|
|
5
|
+
* - Label and metadata for display in adapter dropdowns
|
|
6
|
+
* - Config field definitions for the agent configuration form
|
|
7
|
+
* - A buildAdapterConfig helper that maps form values to adapter config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface UIConfigField {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
type: "text" | "password" | "url" | "select" | "number";
|
|
14
|
+
description: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
defaultValue?: string | number;
|
|
17
|
+
options?: { label: string; value: string }[];
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
sensitive?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UIAdapterInfo {
|
|
23
|
+
label: string;
|
|
24
|
+
description: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
configFields: UIConfigField[];
|
|
27
|
+
buildAdapterConfig: (values: Record<string, unknown>) => Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the adapter config schema into UI-renderable config fields.
|
|
32
|
+
* This is called by the Paperclip UI to generate the configuration form.
|
|
33
|
+
*/
|
|
34
|
+
export function parseConfigSchema(): UIAdapterInfo {
|
|
35
|
+
return {
|
|
36
|
+
label: "Agrenting",
|
|
37
|
+
description:
|
|
38
|
+
"Remote AI agent via the Agrenting platform. Submit tasks to agents on agrenting.com using the CACP protocol.",
|
|
39
|
+
icon: "agrenting",
|
|
40
|
+
configFields: [
|
|
41
|
+
{
|
|
42
|
+
key: "agrentingUrl",
|
|
43
|
+
label: "Agrenting URL",
|
|
44
|
+
type: "url",
|
|
45
|
+
description: "Base URL of the Agrenting platform",
|
|
46
|
+
required: true,
|
|
47
|
+
defaultValue: "https://www.agrenting.com",
|
|
48
|
+
placeholder: "https://www.agrenting.com",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: "apiKey",
|
|
52
|
+
label: "API Key",
|
|
53
|
+
type: "password",
|
|
54
|
+
description: "Your Agrenting API key for authentication",
|
|
55
|
+
required: true,
|
|
56
|
+
sensitive: true,
|
|
57
|
+
placeholder: "ak_...",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "agentDid",
|
|
61
|
+
label: "Agent DID",
|
|
62
|
+
type: "text",
|
|
63
|
+
description:
|
|
64
|
+
"Decentralized identifier of the target agent (did:agrenting:...)",
|
|
65
|
+
required: true,
|
|
66
|
+
placeholder: "did:agrenting:your-agent-id",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "webhookSecret",
|
|
70
|
+
label: "Webhook Secret",
|
|
71
|
+
type: "password",
|
|
72
|
+
description:
|
|
73
|
+
"Signing secret for verifying task completion webhooks from Agrenting",
|
|
74
|
+
sensitive: true,
|
|
75
|
+
placeholder: "whsec_...",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: "webhookCallbackUrl",
|
|
79
|
+
label: "Webhook Callback URL",
|
|
80
|
+
type: "url",
|
|
81
|
+
description:
|
|
82
|
+
"Public URL where Agrenting should POST task events. Leave empty to use the built-in listener.",
|
|
83
|
+
placeholder: "https://your-host:8765/webhook",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: "pricingModel",
|
|
87
|
+
label: "Pricing Model",
|
|
88
|
+
type: "select",
|
|
89
|
+
description: "How this agent is billed",
|
|
90
|
+
defaultValue: "fixed",
|
|
91
|
+
options: [
|
|
92
|
+
{ label: "Fixed price per task", value: "fixed" },
|
|
93
|
+
{ label: "Per-token usage", value: "per-token" },
|
|
94
|
+
{ label: "Subscription", value: "subscription" },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "timeoutSec",
|
|
99
|
+
label: "Timeout (seconds)",
|
|
100
|
+
type: "number",
|
|
101
|
+
description: "Maximum time to wait for task completion",
|
|
102
|
+
defaultValue: 600,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
key: "instructionsBundleMode",
|
|
106
|
+
label: "Instructions Mode",
|
|
107
|
+
type: "select",
|
|
108
|
+
description:
|
|
109
|
+
"How task instructions are delivered to the remote agent",
|
|
110
|
+
defaultValue: "inline",
|
|
111
|
+
options: [
|
|
112
|
+
{ label: "Inline (included in task payload)", value: "inline" },
|
|
113
|
+
{
|
|
114
|
+
label: "Managed (uploaded to Agrenting documents API)",
|
|
115
|
+
value: "managed",
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
buildAdapterConfig: (values: Record<string, unknown>) => ({
|
|
121
|
+
agrentingUrl: values.agrentingUrl ?? "https://www.agrenting.com",
|
|
122
|
+
apiKey: values.apiKey,
|
|
123
|
+
agentDid: values.agentDid,
|
|
124
|
+
webhookSecret: values.webhookSecret,
|
|
125
|
+
webhookCallbackUrl: values.webhookCallbackUrl || undefined,
|
|
126
|
+
pricingModel: values.pricingModel ?? "fixed",
|
|
127
|
+
timeoutSec: Number(values.timeoutSec) || 600,
|
|
128
|
+
instructionsBundleMode: values.instructionsBundleMode ?? "inline",
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
}
|
package/ui/src/index.ts
ADDED