feat(plugins): expose the decision lifecycle as plugin events

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.
This commit is contained in:
conreo 2026-09-12 12:11:50 +07:00
parent eb9f954bae
commit aa72015a2f
4 changed files with 94 additions and 1 deletions

View File

@ -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`

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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;
}