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:
parent
eb9f954bae
commit
aa72015a2f
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue