@acnlabs/paperclip-plugin-acn 0.1.0 → 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/CHANGELOG.md +82 -0
- package/README.md +13 -13
- package/SKILL.md +155 -0
- package/dist/constants.d.ts +6 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +6 -2
- package/dist/constants.js.map +1 -1
- package/dist/lib/org-api.d.ts +58 -0
- package/dist/lib/org-api.d.ts.map +1 -0
- package/dist/lib/org-api.js +111 -0
- package/dist/lib/org-api.js.map +1 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +16 -9
- package/dist/manifest.js.map +1 -1
- package/dist/ui/index.d.ts +9 -5
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +22 -2
- package/dist/ui/index.js.map +2 -2
- package/dist/worker.d.ts +70 -8
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +480 -65
- package/dist/worker.js.map +1 -1
- package/package.json +6 -4
package/dist/worker.js
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
* ACN Plugin Worker
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
|
-
* P0-1 Startup :
|
|
6
|
-
* P0-2 Startup : full sync — pull open ACN tasks → Paperclip issues
|
|
7
|
-
* P0-3 ACN→PC : task.* webhook events → sync Paperclip issue status / comments
|
|
8
|
-
* P0-4 PC→ACN : Paperclip issue done/cancelled → ACN review (
|
|
9
|
-
*
|
|
5
|
+
* P0-1 Startup : resolve/create ACN Org; register subnet harness webhook
|
|
6
|
+
* P0-2 Startup : full sync — pull open ACN tasks → Paperclip issues (legacy)
|
|
7
|
+
* P0-3 ACN→PC : task.* webhook events → sync Paperclip issue status / comments (legacy)
|
|
8
|
+
* P0-4 PC→ACN : Paperclip issue done/cancelled → ACN task review (legacy)
|
|
9
|
+
* P2c-C1 PC→ACN : Paperclip issue created → Org work (NOT Task Pool)
|
|
10
|
+
* P2c-C2 ACN→PC : org.work_* / org.loop_tick → Issues (preferred inbound)
|
|
11
|
+
* P2c-C3 PC→ACN : Paperclip issue done/cancelled → PATCH Org work status
|
|
10
12
|
*/
|
|
11
13
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
|
12
14
|
import { ACNClient } from "acn-client";
|
|
13
15
|
import { PLUGIN_ID, STATE_KEYS, WEBHOOK_KEYS } from "./constants.js";
|
|
16
|
+
import { AcnHttpError, AcnOrgApi, orgSubnetId } from "./lib/org-api.js";
|
|
14
17
|
import { verifyAcnSignature } from "./lib/signature.js";
|
|
15
18
|
import { shouldSkipPluginEcho } from "./lib/echo-guard.js";
|
|
16
19
|
import { resolveSecretOrLiteral } from "./lib/secrets.js";
|
|
@@ -25,10 +28,63 @@ let _harnessSecret = null;
|
|
|
25
28
|
/**
|
|
26
29
|
* ACN agent_id of the API key the plugin is configured with. Used to detect
|
|
27
30
|
* — and skip — webhook events fired for tasks the plugin itself just
|
|
28
|
-
* created (
|
|
29
|
-
* would otherwise mirror it back into a ghost duplicate issue).
|
|
31
|
+
* created (legacy Task path).
|
|
30
32
|
*/
|
|
31
33
|
let _selfAgentId = null;
|
|
34
|
+
/**
|
|
35
|
+
* work_ids successfully mapped after C1 outbound. Covers async/retry
|
|
36
|
+
* deliveries that arrive after `saveMap` (ACN may retry after sync POST fails).
|
|
37
|
+
*/
|
|
38
|
+
const _recentOutboundWorkIds = new Set();
|
|
39
|
+
const RECENT_OUTBOUND_CAP = 200;
|
|
40
|
+
const _pendingOutboundByCompany = new Map();
|
|
41
|
+
/** Mark a work_id as outbound so late/retry org.work_created skips create. */
|
|
42
|
+
export function noteRecentOutboundWork(workId) {
|
|
43
|
+
_recentOutboundWorkIds.add(workId);
|
|
44
|
+
if (_recentOutboundWorkIds.size > RECENT_OUTBOUND_CAP) {
|
|
45
|
+
const first = _recentOutboundWorkIds.values().next().value;
|
|
46
|
+
if (first !== undefined)
|
|
47
|
+
_recentOutboundWorkIds.delete(first);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Drop a work_id from the echo set (e.g. after saveMap failure). */
|
|
51
|
+
export function forgetRecentOutboundWork(workId) {
|
|
52
|
+
_recentOutboundWorkIds.delete(workId);
|
|
53
|
+
}
|
|
54
|
+
/** Register an in-flight outbound create before awaiting createWork. */
|
|
55
|
+
export function beginOutboundWorkCreate(companyId, issueId, title) {
|
|
56
|
+
const stack = _pendingOutboundByCompany.get(companyId) ?? [];
|
|
57
|
+
stack.push({ issueId, title });
|
|
58
|
+
_pendingOutboundByCompany.set(companyId, stack);
|
|
59
|
+
}
|
|
60
|
+
/** Clear in-flight entry after createWork settles (success or failure). */
|
|
61
|
+
export function endOutboundWorkCreate(companyId, issueId) {
|
|
62
|
+
const stack = _pendingOutboundByCompany.get(companyId);
|
|
63
|
+
if (!stack?.length)
|
|
64
|
+
return;
|
|
65
|
+
const idx = stack.findIndex((p) => p.issueId === issueId);
|
|
66
|
+
if (idx >= 0)
|
|
67
|
+
stack.splice(idx, 1);
|
|
68
|
+
if (stack.length === 0)
|
|
69
|
+
_pendingOutboundByCompany.delete(companyId);
|
|
70
|
+
else
|
|
71
|
+
_pendingOutboundByCompany.set(companyId, stack);
|
|
72
|
+
}
|
|
73
|
+
function peekPendingOutbound(companyId) {
|
|
74
|
+
const stack = _pendingOutboundByCompany.get(companyId);
|
|
75
|
+
if (!stack?.length)
|
|
76
|
+
return undefined;
|
|
77
|
+
return stack[stack.length - 1];
|
|
78
|
+
}
|
|
79
|
+
/** Test helper — clear outbound echo + in-flight stacks. */
|
|
80
|
+
export function clearRecentOutboundWorkForTests() {
|
|
81
|
+
_recentOutboundWorkIds.clear();
|
|
82
|
+
_pendingOutboundByCompany.clear();
|
|
83
|
+
_lastLoopTickCommentAt = 0;
|
|
84
|
+
}
|
|
85
|
+
/** Cooldown so loop_tick does not spam issue comments. */
|
|
86
|
+
const LOOP_TICK_COMMENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
87
|
+
let _lastLoopTickCommentAt = 0;
|
|
32
88
|
async function loadMap(ctx, key, companyId) {
|
|
33
89
|
// Paperclip's plugin_state.value_json is a jsonb column, so the host can
|
|
34
90
|
// return either a parsed object or a JSON string depending on how the
|
|
@@ -48,14 +104,116 @@ async function loadMap(ctx, key, companyId) {
|
|
|
48
104
|
async function saveMap(ctx, key, companyId, map) {
|
|
49
105
|
await ctx.state.set({ scopeKind: "company", scopeId: companyId, stateKey: key }, JSON.stringify(map));
|
|
50
106
|
}
|
|
107
|
+
async function loadScalar(ctx, key, companyId) {
|
|
108
|
+
const raw = await ctx.state.get({ scopeKind: "company", scopeId: companyId, stateKey: key });
|
|
109
|
+
if (raw == null || raw === "")
|
|
110
|
+
return null;
|
|
111
|
+
if (typeof raw === "string") {
|
|
112
|
+
const s = raw.trim();
|
|
113
|
+
if (!s || s === "null")
|
|
114
|
+
return null;
|
|
115
|
+
// Historically some hosts JSON-encode scalars.
|
|
116
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(s);
|
|
119
|
+
return typeof parsed === "string" && parsed ? parsed : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async function saveScalar(ctx, key, companyId, value) {
|
|
130
|
+
await ctx.state.set({ scopeKind: "company", scopeId: companyId, stateKey: key }, value);
|
|
131
|
+
}
|
|
51
132
|
/** Reverse-lookup: find the key whose value equals `val` */
|
|
52
133
|
function reverseLookup(map, val) {
|
|
53
134
|
return Object.entries(map).find(([, v]) => v === val)?.[0];
|
|
54
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve configured / persisted Org, or create one bound to ``acnSubnetId``.
|
|
138
|
+
* Mutates ``cfg.acnOrgId`` / ``cfg.acnSubnetId`` when filled from Org record.
|
|
139
|
+
*
|
|
140
|
+
* On ``subnet_already_bound`` (409): if the error body hints a ``org_…`` id,
|
|
141
|
+
* reuse it; otherwise fail with an operator-facing message to set ``acnOrgId``.
|
|
142
|
+
*/
|
|
143
|
+
export async function resolveAcnOrg(ctx, cfg, orgApi, companyId, companyName) {
|
|
144
|
+
let orgId = (cfg.acnOrgId ?? "").trim();
|
|
145
|
+
if (!orgId && companyId) {
|
|
146
|
+
orgId = (await loadScalar(ctx, STATE_KEYS.acnOrgId, companyId)) ?? "";
|
|
147
|
+
}
|
|
148
|
+
if (orgId) {
|
|
149
|
+
const org = await orgApi.getOrg(orgId);
|
|
150
|
+
const fence = orgSubnetId(org);
|
|
151
|
+
const configuredSubnet = (cfg.acnSubnetId ?? "").trim();
|
|
152
|
+
if (fence && configuredSubnet && fence !== configuredSubnet) {
|
|
153
|
+
ctx.logger.warn("acn-plugin: acnSubnetId does not match Org fence — using Org fence for harness", {
|
|
154
|
+
acnOrgId: org.org_id,
|
|
155
|
+
configured_subnet: configuredSubnet,
|
|
156
|
+
org_fence: fence,
|
|
157
|
+
});
|
|
158
|
+
cfg.acnSubnetId = fence;
|
|
159
|
+
}
|
|
160
|
+
else if (fence && !configuredSubnet) {
|
|
161
|
+
cfg.acnSubnetId = fence;
|
|
162
|
+
}
|
|
163
|
+
cfg.acnOrgId = org.org_id;
|
|
164
|
+
if (companyId) {
|
|
165
|
+
await saveScalar(ctx, STATE_KEYS.acnOrgId, companyId, org.org_id);
|
|
166
|
+
}
|
|
167
|
+
return org.org_id;
|
|
168
|
+
}
|
|
169
|
+
const subnet = (cfg.acnSubnetId ?? "").trim();
|
|
170
|
+
if (!subnet) {
|
|
171
|
+
throw new Error("acnOrgId or acnSubnetId required: set an existing Org, or a subnet to create one");
|
|
172
|
+
}
|
|
173
|
+
const displayName = (companyName && companyName.trim()) ||
|
|
174
|
+
(companyId ? `Paperclip ${companyId}` : "Paperclip Org");
|
|
175
|
+
try {
|
|
176
|
+
const created = await orgApi.createOrg({
|
|
177
|
+
display_name: displayName,
|
|
178
|
+
subnet_id: subnet,
|
|
179
|
+
});
|
|
180
|
+
cfg.acnOrgId = created.org_id;
|
|
181
|
+
if (companyId) {
|
|
182
|
+
await saveScalar(ctx, STATE_KEYS.acnOrgId, companyId, created.org_id);
|
|
183
|
+
}
|
|
184
|
+
ctx.logger.info("acn-plugin: created ACN Org for company", {
|
|
185
|
+
org_id: created.org_id,
|
|
186
|
+
subnet_id: subnet,
|
|
187
|
+
company_id: companyId,
|
|
188
|
+
});
|
|
189
|
+
return created.org_id;
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (err instanceof AcnHttpError && err.status === 409) {
|
|
193
|
+
const hint = err.boundOrgIdHint;
|
|
194
|
+
if (hint) {
|
|
195
|
+
ctx.logger.warn("acn-plugin: subnet already bound — reusing bound Org from error hint", { subnet_id: subnet, org_id: hint, reason: err.reason });
|
|
196
|
+
const org = await orgApi.getOrg(hint);
|
|
197
|
+
cfg.acnOrgId = org.org_id;
|
|
198
|
+
const fence = orgSubnetId(org);
|
|
199
|
+
if (fence)
|
|
200
|
+
cfg.acnSubnetId = fence;
|
|
201
|
+
if (companyId) {
|
|
202
|
+
await saveScalar(ctx, STATE_KEYS.acnOrgId, companyId, org.org_id);
|
|
203
|
+
}
|
|
204
|
+
return org.org_id;
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`Subnet '${subnet}' is already bound to an ACN Org (reason=${err.reason ?? "conflict"}). ` +
|
|
207
|
+
`Set instance config acnOrgId to that org_id and restart the plugin. ` +
|
|
208
|
+
`Original: ${err.message}`);
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
55
213
|
// ── ACN client factory ────────────────────────────────────────────────────────
|
|
56
214
|
function buildClient(cfg, apiKey) {
|
|
57
215
|
return new ACNClient({
|
|
58
|
-
baseUrl: cfg.acnBaseUrl ?? "https://
|
|
216
|
+
baseUrl: cfg.acnBaseUrl ?? "https://api.acnlabs.dev",
|
|
59
217
|
apiKey,
|
|
60
218
|
});
|
|
61
219
|
}
|
|
@@ -117,6 +275,167 @@ async function syncTasks(ctx, client, cfg, companyId, taskIssueMap) {
|
|
|
117
275
|
}
|
|
118
276
|
return updated;
|
|
119
277
|
}
|
|
278
|
+
// ── Org work inbound (P2c-C2) ─────────────────────────────────────────────────
|
|
279
|
+
function workIssueDescription(data) {
|
|
280
|
+
const lines = [];
|
|
281
|
+
if (data.work_id)
|
|
282
|
+
lines.push(`**ACN Work ID:** \`${data.work_id}\``);
|
|
283
|
+
if (data.org_id)
|
|
284
|
+
lines.push(`**ACN Org ID:** \`${data.org_id}\``);
|
|
285
|
+
if (data.assignee_agent_id) {
|
|
286
|
+
lines.push(`**Assignee:** \`${data.assignee_agent_id}\``);
|
|
287
|
+
}
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
function configuredOrgId(cfg) {
|
|
291
|
+
return (cfg.acnOrgId ?? "").trim();
|
|
292
|
+
}
|
|
293
|
+
export async function handleOrgWorkCreated(ctx, cfg, companyId, data) {
|
|
294
|
+
const orgId = configuredOrgId(cfg);
|
|
295
|
+
const eventOrgId = (data.org_id ?? "").trim();
|
|
296
|
+
if (!orgId || !eventOrgId || eventOrgId !== orgId) {
|
|
297
|
+
ctx.logger.debug("acn-plugin: org.work_created skipped — org mismatch", {
|
|
298
|
+
event_org: eventOrgId || null,
|
|
299
|
+
configured_org: orgId || null,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const workId = (data.work_id ?? "").trim();
|
|
304
|
+
if (!workId)
|
|
305
|
+
return;
|
|
306
|
+
if (_recentOutboundWorkIds.has(workId)) {
|
|
307
|
+
ctx.logger.info("acn-plugin: skipping org.work_created (outbound echo)", {
|
|
308
|
+
work_id: workId,
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const workMap = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
313
|
+
if (workMap[workId]) {
|
|
314
|
+
ctx.logger.info("acn-plugin: skipping org.work_created (already mapped)", {
|
|
315
|
+
work_id: workId,
|
|
316
|
+
issue_id: workMap[workId],
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// Sync harness: bind to the Paperclip issue currently inside createWork.
|
|
321
|
+
const pending = peekPendingOutbound(companyId);
|
|
322
|
+
if (pending) {
|
|
323
|
+
try {
|
|
324
|
+
const fresh = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
325
|
+
fresh[workId] = pending.issueId;
|
|
326
|
+
await saveMap(ctx, STATE_KEYS.issueWorkMap, companyId, fresh);
|
|
327
|
+
noteRecentOutboundWork(workId);
|
|
328
|
+
ctx.logger.info("acn-plugin: org.work_created → bound to in-flight issue", {
|
|
329
|
+
work_id: workId,
|
|
330
|
+
issue_id: pending.issueId,
|
|
331
|
+
org_id: orgId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
forgetRecentOutboundWork(workId);
|
|
336
|
+
ctx.logger.error("acn-plugin: failed to bind org.work_created to in-flight issue", {
|
|
337
|
+
work_id: workId,
|
|
338
|
+
issue_id: pending.issueId,
|
|
339
|
+
error: String(err),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!cfg.autoCreateIssues)
|
|
345
|
+
return;
|
|
346
|
+
const issue = await ctx.issues.create({
|
|
347
|
+
companyId,
|
|
348
|
+
title: `[ACN] ${data.title ?? workId}`,
|
|
349
|
+
description: workIssueDescription(data),
|
|
350
|
+
status: "todo",
|
|
351
|
+
originKind: `plugin:${PLUGIN_ID}:work`,
|
|
352
|
+
originId: workId,
|
|
353
|
+
});
|
|
354
|
+
try {
|
|
355
|
+
const fresh = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
356
|
+
fresh[workId] = issue.id;
|
|
357
|
+
await saveMap(ctx, STATE_KEYS.issueWorkMap, companyId, fresh);
|
|
358
|
+
noteRecentOutboundWork(workId);
|
|
359
|
+
ctx.logger.info("acn-plugin: org.work_created → issue created", {
|
|
360
|
+
work_id: workId,
|
|
361
|
+
issue_id: issue.id,
|
|
362
|
+
org_id: orgId,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
forgetRecentOutboundWork(workId);
|
|
367
|
+
ctx.logger.error("acn-plugin: org.work_created issue created but map persist failed", {
|
|
368
|
+
work_id: workId,
|
|
369
|
+
issue_id: issue.id,
|
|
370
|
+
error: String(err),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
export async function handleOrgWorkUpdated(ctx, cfg, companyId, data) {
|
|
375
|
+
const orgId = configuredOrgId(cfg);
|
|
376
|
+
const eventOrgId = (data.org_id ?? "").trim();
|
|
377
|
+
if (!orgId || !eventOrgId || eventOrgId !== orgId)
|
|
378
|
+
return;
|
|
379
|
+
const workId = (data.work_id ?? "").trim();
|
|
380
|
+
if (!workId)
|
|
381
|
+
return;
|
|
382
|
+
const workMap = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
383
|
+
const issueId = workMap[workId];
|
|
384
|
+
if (!issueId)
|
|
385
|
+
return;
|
|
386
|
+
const status = data.status;
|
|
387
|
+
if (!status)
|
|
388
|
+
return;
|
|
389
|
+
// Same constraint as task.accepted: Paperclip in_progress needs an assignee
|
|
390
|
+
// in Paperclip's identity space — ACN agent ids are not assignable.
|
|
391
|
+
if (status === "in_progress") {
|
|
392
|
+
const who = data.assignee_agent_id ?? "unknown";
|
|
393
|
+
await ctx.issues.createComment(issueId, `Org work moved to \`in_progress\` on ACN (assignee \`${who}\`).`, companyId);
|
|
394
|
+
ctx.logger.info("acn-plugin: org.work_updated → comment (in_progress)", {
|
|
395
|
+
work_id: workId,
|
|
396
|
+
issue_id: issueId,
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (status === "todo" || status === "done" || status === "cancelled") {
|
|
401
|
+
await ctx.issues.update(issueId, { status }, companyId);
|
|
402
|
+
await ctx.issues.createComment(issueId, `Org work status on ACN is now \`${status}\`.`, companyId);
|
|
403
|
+
ctx.logger.info("acn-plugin: org.work_updated → issue status", {
|
|
404
|
+
work_id: workId,
|
|
405
|
+
issue_id: issueId,
|
|
406
|
+
status,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
export async function handleOrgLoopTick(ctx, cfg, companyId, data) {
|
|
411
|
+
const orgId = configuredOrgId(cfg);
|
|
412
|
+
const eventOrgId = (data.org_id ?? "").trim();
|
|
413
|
+
if (!orgId || !eventOrgId || eventOrgId !== orgId)
|
|
414
|
+
return;
|
|
415
|
+
const workIds = Array.isArray(data.work_ids) ? data.work_ids : [];
|
|
416
|
+
const openCount = data.open_count ?? workIds.length;
|
|
417
|
+
ctx.logger.info("acn-plugin: org.loop_tick", {
|
|
418
|
+
org_id: orgId,
|
|
419
|
+
open_count: openCount,
|
|
420
|
+
work_ids: workIds,
|
|
421
|
+
});
|
|
422
|
+
if (workIds.length === 0)
|
|
423
|
+
return;
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (now - _lastLoopTickCommentAt < LOOP_TICK_COMMENT_COOLDOWN_MS) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const workMap = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
429
|
+
// One comment per tick (first mapped open work) to avoid comment spam.
|
|
430
|
+
for (const workId of workIds) {
|
|
431
|
+
const issueId = workMap[workId];
|
|
432
|
+
if (!issueId)
|
|
433
|
+
continue;
|
|
434
|
+
await ctx.issues.createComment(issueId, `Org loop tick — ${openCount} open work item(s) on ACN.`, companyId);
|
|
435
|
+
_lastLoopTickCommentAt = now;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
120
439
|
// ── ACN webhook event handler ─────────────────────────────────────────────────
|
|
121
440
|
async function handleAcnWebhook(ctx, cfg, client, rawBody) {
|
|
122
441
|
let payload;
|
|
@@ -134,8 +453,29 @@ async function handleAcnWebhook(ctx, cfg, client, rawBody) {
|
|
|
134
453
|
const companyId = companies[0]?.id;
|
|
135
454
|
if (!companyId)
|
|
136
455
|
return;
|
|
456
|
+
const { data } = payload;
|
|
457
|
+
// Preferred Org Harness path (P2c-C2) — must run before task.* so we never
|
|
458
|
+
// treat the overloaded task_id (org_id) as a Task Pool id.
|
|
459
|
+
if (payload.event === "org.work_created") {
|
|
460
|
+
await handleOrgWorkCreated(ctx, cfg, companyId, data);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (payload.event === "org.work_updated") {
|
|
464
|
+
await handleOrgWorkUpdated(ctx, cfg, companyId, data);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (payload.event === "org.loop_tick") {
|
|
468
|
+
await handleOrgLoopTick(ctx, cfg, companyId, data);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (payload.event.startsWith("org.")) {
|
|
472
|
+
ctx.logger.debug("acn-plugin: unhandled org event", { event: payload.event });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const task_id = payload.task_id ?? "";
|
|
476
|
+
if (!task_id)
|
|
477
|
+
return;
|
|
137
478
|
const taskIssueMap = await loadMap(ctx, STATE_KEYS.issueTaskMap, companyId);
|
|
138
|
-
const { task_id, data } = payload;
|
|
139
479
|
switch (payload.event) {
|
|
140
480
|
case "task.created": {
|
|
141
481
|
if (!cfg.autoCreateIssues)
|
|
@@ -143,7 +483,7 @@ async function handleAcnWebhook(ctx, cfg, client, rawBody) {
|
|
|
143
483
|
if (taskIssueMap[task_id])
|
|
144
484
|
break;
|
|
145
485
|
// Echo guard: skip tasks the bridge agent itself created. The plugin's
|
|
146
|
-
//
|
|
486
|
+
// legacy outbound path also called ACN createTask synchronously,
|
|
147
487
|
// which fires this very `task.created` webhook *before* `saveMap` has
|
|
148
488
|
// had a chance to persist the mapping. The map check above will miss
|
|
149
489
|
// and we'd ghost-mirror our own outbound task back into a second
|
|
@@ -243,7 +583,7 @@ async function handleAcnWebhook(ctx, cfg, client, rawBody) {
|
|
|
243
583
|
}
|
|
244
584
|
}
|
|
245
585
|
// ── Paperclip issue event handlers ────────────────────────────────────────────
|
|
246
|
-
export async function handleIssueUpdated(ctx, cfg, client, event) {
|
|
586
|
+
export async function handleIssueUpdated(ctx, cfg, client, orgApi, event) {
|
|
247
587
|
if (shouldSkipPluginEcho(event, PLUGIN_ID))
|
|
248
588
|
return;
|
|
249
589
|
// See handleIssueCreated: Paperclip carries the issue id on the event
|
|
@@ -256,13 +596,46 @@ export async function handleIssueUpdated(ctx, cfg, client, event) {
|
|
|
256
596
|
const status = payload.status;
|
|
257
597
|
if (!status)
|
|
258
598
|
return;
|
|
599
|
+
if (status !== "done" && status !== "cancelled")
|
|
600
|
+
return;
|
|
259
601
|
const companyId = event.companyId;
|
|
602
|
+
const orgId = (cfg.acnOrgId ?? "").trim();
|
|
603
|
+
// P2c-C3: Org-backed issues → PATCH work status (preferred path).
|
|
604
|
+
const workMap = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
605
|
+
const workId = reverseLookup(workMap, issueId);
|
|
606
|
+
if (workId && orgId) {
|
|
607
|
+
if (status === "done" && !cfg.autoApproveOnDone) {
|
|
608
|
+
ctx.logger.info("acn-plugin: skip Org work done — autoApproveOnDone=false", {
|
|
609
|
+
work_id: workId,
|
|
610
|
+
issue_id: issueId,
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
await orgApi.updateWorkStatus(orgId, workId, { status });
|
|
616
|
+
ctx.logger.info("acn-plugin: issue status → Org work PATCH", {
|
|
617
|
+
work_id: workId,
|
|
618
|
+
org_id: orgId,
|
|
619
|
+
issue_id: issueId,
|
|
620
|
+
status,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
ctx.logger.error("acn-plugin: Org work status PATCH failed", {
|
|
625
|
+
work_id: workId,
|
|
626
|
+
org_id: orgId,
|
|
627
|
+
issue_id: issueId,
|
|
628
|
+
status,
|
|
629
|
+
error: String(err),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Legacy Task Pool review path.
|
|
260
635
|
const taskIssueMap = await loadMap(ctx, STATE_KEYS.issueTaskMap, companyId);
|
|
261
636
|
const taskId = reverseLookup(taskIssueMap, issueId);
|
|
262
637
|
if (!taskId)
|
|
263
638
|
return;
|
|
264
|
-
if (status !== "done" && status !== "cancelled")
|
|
265
|
-
return;
|
|
266
639
|
// Only call /review when ACN considers the task to be awaiting review
|
|
267
640
|
// (`submitted`). For other statuses (open / in_progress / completed /...)
|
|
268
641
|
// the review endpoint will 400.
|
|
@@ -288,7 +661,7 @@ export async function handleIssueUpdated(ctx, cfg, client, event) {
|
|
|
288
661
|
}
|
|
289
662
|
}
|
|
290
663
|
}
|
|
291
|
-
export async function handleIssueCreated(ctx, cfg,
|
|
664
|
+
export async function handleIssueCreated(ctx, cfg, orgApi, event) {
|
|
292
665
|
if (shouldSkipPluginEcho(event, PLUGIN_ID))
|
|
293
666
|
return;
|
|
294
667
|
// Paperclip carries entity identity at the event envelope level
|
|
@@ -300,60 +673,68 @@ export async function handleIssueCreated(ctx, cfg, client, event) {
|
|
|
300
673
|
return;
|
|
301
674
|
const issueId = event.entityId;
|
|
302
675
|
const companyId = event.companyId;
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
const payload = (event.payload ?? {});
|
|
307
|
-
let description = "";
|
|
308
|
-
try {
|
|
309
|
-
const full = (await ctx.issues.get(issueId, companyId));
|
|
310
|
-
description = full?.description?.trim() ?? "";
|
|
311
|
-
}
|
|
312
|
-
catch (err) {
|
|
313
|
-
ctx.logger.warn("acn-plugin: failed to fetch issue body, falling back to title", {
|
|
676
|
+
const orgId = (cfg.acnOrgId ?? "").trim();
|
|
677
|
+
if (!orgId) {
|
|
678
|
+
ctx.logger.error("acn-plugin: issue.created skipped — acnOrgId not resolved", {
|
|
314
679
|
issue_id: issueId,
|
|
315
|
-
error: String(err),
|
|
316
680
|
});
|
|
681
|
+
return;
|
|
317
682
|
}
|
|
683
|
+
// Dedup against both Org work map and legacy Task map (inbound task.* may
|
|
684
|
+
// still create issues during the transition).
|
|
685
|
+
const [workMap, taskMap] = await Promise.all([
|
|
686
|
+
loadMap(ctx, STATE_KEYS.issueWorkMap, companyId),
|
|
687
|
+
loadMap(ctx, STATE_KEYS.issueTaskMap, companyId),
|
|
688
|
+
]);
|
|
689
|
+
if (reverseLookup(workMap, issueId) || reverseLookup(taskMap, issueId))
|
|
690
|
+
return;
|
|
691
|
+
const payload = (event.payload ?? {});
|
|
692
|
+
const title = payload.title ?? issueId;
|
|
693
|
+
// Must register before await: ACN delivers org.work_created inline.
|
|
694
|
+
beginOutboundWorkCreate(companyId, issueId, title);
|
|
318
695
|
try {
|
|
319
|
-
const
|
|
320
|
-
title
|
|
321
|
-
description: description || "Task created from Paperclip issue.",
|
|
322
|
-
reward: "0",
|
|
323
|
-
deadline_hours: 168,
|
|
324
|
-
subnet_id: cfg.acnSubnetId ?? null,
|
|
325
|
-
reward_currency: "credits",
|
|
696
|
+
const work = await orgApi.createWork(orgId, {
|
|
697
|
+
title,
|
|
326
698
|
});
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
ctx.logger.info("acn-plugin: issue.created → ACN task created", {
|
|
699
|
+
const fresh = await loadMap(ctx, STATE_KEYS.issueWorkMap, companyId);
|
|
700
|
+
// Webhook may have already bound work_id → this issue during createWork.
|
|
701
|
+
if (!fresh[work.work_id]) {
|
|
702
|
+
fresh[work.work_id] = issueId;
|
|
703
|
+
await saveMap(ctx, STATE_KEYS.issueWorkMap, companyId, fresh);
|
|
704
|
+
}
|
|
705
|
+
noteRecentOutboundWork(work.work_id);
|
|
706
|
+
ctx.logger.info("acn-plugin: issue.created → Org work created", {
|
|
336
707
|
issue_id: issueId,
|
|
337
|
-
|
|
708
|
+
org_id: orgId,
|
|
709
|
+
work_id: work.work_id,
|
|
338
710
|
});
|
|
339
711
|
}
|
|
340
712
|
catch (err) {
|
|
341
|
-
ctx.logger.error("acn-plugin: failed to create
|
|
713
|
+
ctx.logger.error("acn-plugin: failed to create Org work for issue", {
|
|
342
714
|
issue_id: issueId,
|
|
715
|
+
org_id: orgId,
|
|
343
716
|
error: String(err),
|
|
344
717
|
});
|
|
345
718
|
}
|
|
719
|
+
finally {
|
|
720
|
+
endOutboundWorkCreate(companyId, issueId);
|
|
721
|
+
}
|
|
346
722
|
}
|
|
347
723
|
// ── Plugin definition ─────────────────────────────────────────────────────────
|
|
348
724
|
const plugin = definePlugin({
|
|
349
725
|
async setup(ctx) {
|
|
350
726
|
const cfg = (await ctx.config.get());
|
|
351
|
-
if (!cfg.acnApiKeyRef
|
|
352
|
-
ctx.logger.warn("acn-plugin: acnApiKeyRef
|
|
727
|
+
if (!cfg.acnApiKeyRef) {
|
|
728
|
+
ctx.logger.warn("acn-plugin: acnApiKeyRef not configured — skipping setup");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (!(cfg.acnOrgId ?? "").trim() && !(cfg.acnSubnetId ?? "").trim()) {
|
|
732
|
+
ctx.logger.warn("acn-plugin: acnOrgId or acnSubnetId required — skipping setup");
|
|
353
733
|
return;
|
|
354
734
|
}
|
|
355
735
|
const apiKey = await resolveSecretOrLiteral(cfg.acnApiKeyRef, ctx.secrets);
|
|
356
736
|
const client = buildClient(cfg, apiKey);
|
|
737
|
+
const orgApi = new AcnOrgApi(cfg.acnBaseUrl ?? "https://api.acnlabs.dev", apiKey);
|
|
357
738
|
// Resolve the HMAC secret used for X-ACN-Signature verification.
|
|
358
739
|
let harnessSecret = null;
|
|
359
740
|
if (cfg.acnHarnessSecretRef) {
|
|
@@ -370,17 +751,27 @@ const plugin = definePlugin({
|
|
|
370
751
|
else {
|
|
371
752
|
ctx.logger.warn("acn-plugin: acnHarnessSecretRef not configured — inbound webhook signatures will NOT be verified (insecure)");
|
|
372
753
|
}
|
|
754
|
+
const companies = await ctx.companies.list();
|
|
755
|
+
if (companies.length > 1) {
|
|
756
|
+
ctx.logger.warn("acn-plugin: multiple Paperclip companies found — only the first is bound to one ACN Org (multi-company mapping is not supported yet)", { company_count: companies.length, using: companies[0]?.id });
|
|
757
|
+
}
|
|
758
|
+
const companyId = companies[0]?.id;
|
|
759
|
+
const companyName = companies[0]?.name;
|
|
760
|
+
try {
|
|
761
|
+
await resolveAcnOrg(ctx, cfg, orgApi, companyId, companyName);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
ctx.logger.error("acn-plugin: failed to resolve/create ACN Org — skipping setup", {
|
|
765
|
+
error: String(err),
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
373
769
|
// Stash for onWebhook()
|
|
374
770
|
_ctx = ctx;
|
|
375
771
|
_cfg = cfg;
|
|
376
772
|
_client = client;
|
|
377
773
|
_harnessSecret = harnessSecret;
|
|
378
|
-
//
|
|
379
|
-
// webhook handler distinguish events fired for tasks we created
|
|
380
|
-
// ourselves from events fired for tasks created by other ACN agents,
|
|
381
|
-
// so we don't mirror plugin-issued tasks back into ghost duplicate
|
|
382
|
-
// Paperclip issues. Failure is non-fatal: we simply lose the ghost-
|
|
383
|
-
// mirror guard and fall back to the slower map-based dedup.
|
|
774
|
+
// Legacy task.* echo guard (inbound Task mirror still active).
|
|
384
775
|
try {
|
|
385
776
|
const me = await client.getMyAgent();
|
|
386
777
|
_selfAgentId = me.agent_id;
|
|
@@ -395,11 +786,13 @@ const plugin = definePlugin({
|
|
|
395
786
|
}
|
|
396
787
|
// ── P0-1 Register harness webhook ────────────────────────────────────────
|
|
397
788
|
const webhookUrl = harnessWebhookUrl(cfg);
|
|
398
|
-
|
|
789
|
+
const subnetId = (cfg.acnSubnetId ?? "").trim();
|
|
790
|
+
if (webhookUrl && subnetId) {
|
|
399
791
|
try {
|
|
400
|
-
await client.registerSubnetHarness(
|
|
792
|
+
await client.registerSubnetHarness(subnetId, webhookUrl, harnessSecret);
|
|
401
793
|
ctx.logger.info("acn-plugin: registered harness", {
|
|
402
|
-
subnet_id:
|
|
794
|
+
subnet_id: subnetId,
|
|
795
|
+
org_id: cfg.acnOrgId,
|
|
403
796
|
webhook_url: webhookUrl,
|
|
404
797
|
signed: harnessSecret !== null,
|
|
405
798
|
});
|
|
@@ -408,13 +801,14 @@ const plugin = definePlugin({
|
|
|
408
801
|
ctx.logger.error("acn-plugin: harness registration failed", { error: String(err) });
|
|
409
802
|
}
|
|
410
803
|
}
|
|
411
|
-
else {
|
|
804
|
+
else if (!webhookUrl) {
|
|
412
805
|
ctx.logger.warn("acn-plugin: paperclipBaseUrl not set — skipping harness registration");
|
|
413
806
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
807
|
+
else {
|
|
808
|
+
ctx.logger.warn("acn-plugin: no subnet_id after Org resolve — skipping harness registration");
|
|
809
|
+
}
|
|
810
|
+
// ── P0-2 Full initial task sync (legacy inbound) ─────────────────────────
|
|
811
|
+
if (companyId && subnetId) {
|
|
418
812
|
let taskIssueMap = await loadMap(ctx, STATE_KEYS.issueTaskMap, companyId);
|
|
419
813
|
try {
|
|
420
814
|
taskIssueMap = await syncTasks(ctx, client, cfg, companyId, taskIssueMap);
|
|
@@ -424,13 +818,13 @@ const plugin = definePlugin({
|
|
|
424
818
|
ctx.logger.error("acn-plugin: task full sync failed", { error: String(err) });
|
|
425
819
|
}
|
|
426
820
|
}
|
|
427
|
-
// ──
|
|
821
|
+
// ── P2c-C3 / legacy: issue status → Org work PATCH or Task review ─────────
|
|
428
822
|
ctx.events.on("issue.updated", async (event) => {
|
|
429
|
-
await handleIssueUpdated(ctx, cfg, client, event);
|
|
823
|
+
await handleIssueUpdated(ctx, cfg, client, orgApi, event);
|
|
430
824
|
});
|
|
431
|
-
// ──
|
|
825
|
+
// ── P2c-C1 Paperclip issue created → Org work ────────────────────────────
|
|
432
826
|
ctx.events.on("issue.created", async (event) => {
|
|
433
|
-
await handleIssueCreated(ctx, cfg,
|
|
827
|
+
await handleIssueCreated(ctx, cfg, orgApi, event);
|
|
434
828
|
});
|
|
435
829
|
// ── Bridge: getData for ACN tab ────────────────────────────────────────────
|
|
436
830
|
ctx.data.register("acn-task-info", async (params) => {
|
|
@@ -438,6 +832,21 @@ const plugin = definePlugin({
|
|
|
438
832
|
const cid = params.companyId;
|
|
439
833
|
if (!issueId || !cid)
|
|
440
834
|
return null;
|
|
835
|
+
const workMap = await loadMap(ctx, STATE_KEYS.issueWorkMap, cid);
|
|
836
|
+
const workId = reverseLookup(workMap, issueId);
|
|
837
|
+
if (workId && cfg.acnOrgId) {
|
|
838
|
+
return {
|
|
839
|
+
work_id: workId,
|
|
840
|
+
org_id: cfg.acnOrgId,
|
|
841
|
+
source: "org_work",
|
|
842
|
+
task_id: null,
|
|
843
|
+
title: null,
|
|
844
|
+
status: null,
|
|
845
|
+
reward: null,
|
|
846
|
+
reward_currency: null,
|
|
847
|
+
participations: [],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
441
850
|
const map = await loadMap(ctx, STATE_KEYS.issueTaskMap, cid);
|
|
442
851
|
const taskId = reverseLookup(map, issueId);
|
|
443
852
|
if (!taskId)
|
|
@@ -453,6 +862,9 @@ const plugin = definePlugin({
|
|
|
453
862
|
reward: task.reward,
|
|
454
863
|
reward_currency: task.reward_currency,
|
|
455
864
|
participations,
|
|
865
|
+
source: "task_pool",
|
|
866
|
+
work_id: null,
|
|
867
|
+
org_id: cfg.acnOrgId ?? null,
|
|
456
868
|
};
|
|
457
869
|
});
|
|
458
870
|
// ── Bridge: performAction for manual review ────────────────────────────────
|
|
@@ -463,7 +875,10 @@ const plugin = definePlugin({
|
|
|
463
875
|
await client.reviewTask(taskId, approved, feedback);
|
|
464
876
|
return { ok: true };
|
|
465
877
|
});
|
|
466
|
-
ctx.logger.info("acn-plugin: setup complete", {
|
|
878
|
+
ctx.logger.info("acn-plugin: setup complete", {
|
|
879
|
+
org_id: cfg.acnOrgId,
|
|
880
|
+
subnet_id: cfg.acnSubnetId,
|
|
881
|
+
});
|
|
467
882
|
},
|
|
468
883
|
async onHealth() {
|
|
469
884
|
return { status: "ok", message: "ACN plugin running" };
|