@acnlabs/paperclip-plugin-acn 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worker.js CHANGED
@@ -2,15 +2,18 @@
2
2
  * ACN Plugin Worker
3
3
  *
4
4
  * Responsibilities:
5
- * P0-1 Startup : register webhook URL as ACN subnet harness
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 (approve / reject)
9
- * P0-5 PC→ACN : Paperclip issue created (not from ACN) ACN task
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 (PC issue → plugin createTask → ACN fires task.created → plugin
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,10 +104,112 @@ 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({
@@ -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
- // `handleIssueCreated` path also calls ACN createTask synchronously,
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, client, event) {
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 taskIssueMap = await loadMap(ctx, STATE_KEYS.issueTaskMap, companyId);
304
- if (reverseLookup(taskIssueMap, issueId))
305
- return;
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 task = await client.createTask({
320
- title: payload.title ?? issueId,
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
- // Persist the mapping IMMEDIATELY: ACN's `task.created` webhook (which
328
- // mirrors ACN-side tasks back into Paperclip) fires synchronously from
329
- // `createTask`, so the webhook handler will race this code path. If we
330
- // don't have the map in place before the webhook arrives, the handler
331
- // sees an unknown task_id and creates a ghost duplicate issue.
332
- const fresh = await loadMap(ctx, STATE_KEYS.issueTaskMap, companyId);
333
- fresh[task.task_id] = issueId;
334
- await saveMap(ctx, STATE_KEYS.issueTaskMap, companyId, fresh);
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
- task_id: task.task_id,
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 ACN task for issue", {
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 || !cfg.acnSubnetId) {
352
- ctx.logger.warn("acn-plugin: acnApiKeyRef or acnSubnetId not configured — skipping setup");
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
- // Resolve the configured bridge agent's identity. This lets the ACN
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
- if (webhookUrl) {
789
+ const subnetId = (cfg.acnSubnetId ?? "").trim();
790
+ if (webhookUrl && subnetId) {
399
791
  try {
400
- await client.registerSubnetHarness(cfg.acnSubnetId, webhookUrl, harnessSecret);
792
+ await client.registerSubnetHarness(subnetId, webhookUrl, harnessSecret);
401
793
  ctx.logger.info("acn-plugin: registered harness", {
402
- subnet_id: cfg.acnSubnetId,
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
- // ── P0-2 Full initial task sync ──────────────────────────────────────────
415
- const companies = await ctx.companies.list();
416
- const companyId = companies[0]?.id;
417
- if (companyId) {
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
- // ── P0-4 Paperclip issue status → ACN review ────────────────────────────
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
- // ── P0-5 Paperclip issue created → ACN task ──────────────────────────────
825
+ // ── P2c-C1 Paperclip issue created → Org work ────────────────────────────
432
826
  ctx.events.on("issue.created", async (event) => {
433
- await handleIssueCreated(ctx, cfg, client, event);
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", { subnet_id: cfg.acnSubnetId });
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" };