From aa72015a2fefe3753453ba6061c47de59b32c1c3 Mon Sep 17 00:00:00 2001 From: conreo Date: Sat, 12 Sep 2026 12:11:50 +0700 Subject: [PATCH] feat(plugins): expose the decision lifecycle as plugin events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Decisions Desk is the platform's "a human must choose, by this date" surface, and plugins could not see it. The decisions service logs decision.created with the payload a notifier needs (originIssueId, originAgentId, originResponsibleUserId) and decision_triage carries the decide_by deadline, but none of those actions were in PLUGIN_EVENT_TYPES or in the activity-action map, so eventTypeForActivityAction() returned null and the event bus dropped them. A subscriber could not hear about a decision at all. This exposes the decision entity lifecycle (created / expired / dismissed / cancelled) as plugin event types, adds them to the documented minimum event set, and exports eventTypeForActivityAction for direct testing, matching the existing precedent of resolveResponsibleUserIdForActivity in the same module. Queue, triage, training, and retention actions stay private. They are desk plumbing, and exposing them would make queue internals a compatibility obligation. Refs #6157 — the same failure class: a plugin event that looks subscribed but is never delivered. --- doc/plugins/PLUGIN_SPEC.md | 4 + packages/shared/src/constants.ts | 8 ++ .../__tests__/plugin-decision-events.test.ts | 74 +++++++++++++++++++ server/src/services/activity-log.ts | 9 ++- 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/plugin-decision-events.test.ts diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 08b859c135..b5f4d5d255 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -924,6 +924,10 @@ Minimum event set: - `agent.run.cancelled` - `approval.created` - `approval.decided` +- `decision.created` +- `decision.expired` +- `decision.dismissed` +- `decision.cancelled` - `budget.incident.opened` - `budget.incident.resolved` - `cost_event.created` diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 28e18d3dfd..3655e4aada 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1708,6 +1708,14 @@ export const PLUGIN_EVENT_TYPES = [ "goal.updated", "approval.created", "approval.decided", + // Decisions Desk lifecycle. A decision is the platform's "a human must choose, + // by this date" object, so excluding it left notifications unable to cover the + // surface operators most need to be woken for. The queue, triage, training and + // retention actions stay private: they are desk plumbing, not decisions. + "decision.created", + "decision.expired", + "decision.dismissed", + "decision.cancelled", "budget.incident.opened", "budget.incident.resolved", "cost_event.created", diff --git a/server/src/__tests__/plugin-decision-events.test.ts b/server/src/__tests__/plugin-decision-events.test.ts new file mode 100644 index 0000000000..e9174cf368 --- /dev/null +++ b/server/src/__tests__/plugin-decision-events.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { PLUGIN_EVENT_TYPES } from "@paperclipai/shared"; +import { eventTypeForActivityAction } from "../services/activity-log.js"; + +/** + * The activity-action → plugin-event mapping is a plugin-facing contract that + * fails silently: an unmapped action produces no event, so a subscriber simply + * never hears about it and nothing errors. The Decisions Desk was in exactly that + * state — `decision.created` was logged with the payload a notifier needs + * (`originIssueId`, `originAgentId`, `originResponsibleUserId`) but mapped to + * nothing. + */ +describe("decision lifecycle plugin events", () => { + const decisionActions = [ + "decision.created", + "decision.expired", + "decision.dismissed", + "decision.cancelled", + ] as const; + + it("declares every decision lifecycle action as a subscribable event type", () => { + for (const action of decisionActions) { + expect(PLUGIN_EVENT_TYPES).toContain(action); + } + }); + + it("maps each decision action onto its own event type", () => { + for (const action of decisionActions) { + expect(eventTypeForActivityAction(action)).toBe(action); + } + }); + + it("keeps desk plumbing out of the plugin event surface", () => { + // Queue, triage, training and retention actions are internal desk mechanics. + // Exposing them would invite plugins to depend on queue internals, and each + // one would become a compatibility obligation. + for (const action of [ + "decision_queue_item.added", + "decision_queue.created", + "decision_queue_item.removed", + "decision_triage.updated", + "decision_training.created", + "decision_retention.archived", + ]) { + expect(eventTypeForActivityAction(action)).toBeNull(); + } + }); + + it("does not treat a decision action as a prefix match", () => { + // The pass-through is an exact set lookup, not a prefix rule: a future + // action must be declared deliberately rather than inheriting an event. + expect(eventTypeForActivityAction("decision.created.v2")).toBeNull(); + expect(eventTypeForActivityAction("decision")).toBeNull(); + }); + + it("still maps the legacy underscore actions it already covered", () => { + expect(eventTypeForActivityAction("approval_approved")).toBe("approval.decided"); + expect(eventTypeForActivityAction("approval_rejected")).toBe("approval.decided"); + expect(eventTypeForActivityAction("issue_comment_added")).toBe("issue.comment.created"); + expect(eventTypeForActivityAction("budget_soft_threshold_crossed")).toBe( + "budget.incident.opened", + ); + }); + + it("passes through the event types that were already exact names", () => { + expect(eventTypeForActivityAction("issue.created")).toBe("issue.created"); + expect(eventTypeForActivityAction("approval.created")).toBe("approval.created"); + }); + + it("leaves unrelated activity unmapped", () => { + expect(eventTypeForActivityAction("company.created.note")).toBeNull(); + expect(eventTypeForActivityAction("some.unmapped.action")).toBeNull(); + }); +}); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 4c75f07527..64b9f1356d 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -37,7 +37,14 @@ export function setPluginEventBus(bus: PluginEventBus): void { _pluginEventBus = bus; } -function eventTypeForActivityAction(action: string): PluginEventType | null { +/** + * Resolve the plugin event type for a logged activity action. + * + * Exported for direct testing, following `resolveResponsibleUserIdForActivity`: + * the mapping is the contract plugins subscribe against, and a silent regression + * here is invisible at runtime (the event is simply never delivered). + */ +export function eventTypeForActivityAction(action: string): PluginEventType | null { if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType; return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null; }