@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { parseConfigSchema } from "./adapter.js";
2
+ export type { UIConfigField, UIAdapterInfo } from "./adapter.js";