agent-relay-server 0.92.0 → 0.93.1
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/docs/openapi.json +29 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
- package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
- package/public/assets/{PlanGraphPanel-CpIBMvnu.js → PlanGraphPanel-BuWerKhs.js} +2 -2
- package/public/assets/{PlanGraphPanel-CpIBMvnu.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
- package/public/assets/activity-tuTEn3vn.js +2 -0
- package/public/assets/{activity-B6WPWNsX.js.map → activity-tuTEn3vn.js.map} +1 -1
- package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
- package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
- package/public/assets/agents-B-BSBrM5.js +2 -0
- package/public/assets/{agents-BebF-yQr.js.map → agents-B-BSBrM5.js.map} +1 -1
- package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
- package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
- package/public/assets/{automation-CyPXC1A8.js → automation-BAQM1dBh.js} +2 -2
- package/public/assets/{automation-CyPXC1A8.js.map → automation-BAQM1dBh.js.map} +1 -1
- package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
- package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
- package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
- package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
- package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
- package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
- package/public/assets/card-CpLnx1JJ.js +2 -0
- package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
- package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
- package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
- package/public/assets/chat-uWKl3MpS.js +2 -0
- package/public/assets/{chat-DfqC3kFF.js.map → chat-uWKl3MpS.js.map} +1 -1
- package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
- package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
- package/public/assets/copy-button-CKAwTG9g.js +2 -0
- package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
- package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
- package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
- package/public/assets/dist-BZ-CpDXy.js +2 -0
- package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
- package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
- package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
- package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
- package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
- package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
- package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
- package/public/assets/dist-CEb4q1gZ.js +2 -0
- package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
- package/public/assets/dist-DBDYIgS5.js +2 -0
- package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
- package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
- package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
- package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
- package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
- package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
- package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
- package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
- package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
- package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
- package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CrE-hFvy.js → formatted-body-impl-DuqZunxL.js} +2 -2
- package/public/assets/{formatted-body-impl-CrE-hFvy.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
- package/public/assets/index-DLjkMB-b.css +2 -0
- package/public/assets/index-dMB8klzy.js +23 -0
- package/public/assets/index-dMB8klzy.js.map +1 -0
- package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
- package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
- package/public/assets/{insights-BribQHXe.js → insights-C0lZ7n8D.js} +2 -2
- package/public/assets/{insights-BribQHXe.js.map → insights-C0lZ7n8D.js.map} +1 -1
- package/public/assets/integrations-DXh00Ss-.js +2 -0
- package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
- package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
- package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
- package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
- package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
- package/public/assets/{maintenance-Cjol3fS_.js → maintenance-Dh9nzjYV.js} +2 -2
- package/public/assets/{maintenance-Cjol3fS_.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
- package/public/assets/{managed-agents-1lZwQNHl.js → managed-agents-Bsveljn4.js} +4 -4
- package/public/assets/{managed-agents-1lZwQNHl.js.map → managed-agents-Bsveljn4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C2WeUNFR.js → markdown-preview-impl-CYpMFis9.js} +2 -2
- package/public/assets/{markdown-preview-impl-C2WeUNFR.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
- package/public/assets/{memory-B7mcELEW.js → memory-DMFQMXrp.js} +2 -2
- package/public/assets/{memory-B7mcELEW.js.map → memory-DMFQMXrp.js.map} +1 -1
- package/public/assets/messages-Cnzc7j50.js +2 -0
- package/public/assets/{messages-B8jF9p2l.js.map → messages-Cnzc7j50.js.map} +1 -1
- package/public/assets/{orchestrators-CQjvBXDx.js → orchestrators-DSoDPNVS.js} +2 -2
- package/public/assets/{orchestrators-CQjvBXDx.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
- package/public/assets/{overview-CgDUJOG-.js → overview-OsRxo5XQ.js} +2 -2
- package/public/assets/{overview-CgDUJOG-.js.map → overview-OsRxo5XQ.js.map} +1 -1
- package/public/assets/{pairs-BdPKQyzE.js → pairs-D4UiUhyW.js} +2 -2
- package/public/assets/{pairs-BdPKQyzE.js.map → pairs-D4UiUhyW.js.map} +1 -1
- package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
- package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
- package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
- package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
- package/public/assets/{security-BlZQ0ECI.js → security-CC2F6xdd.js} +2 -2
- package/public/assets/{security-BlZQ0ECI.js.map → security-CC2F6xdd.js.map} +1 -1
- package/public/assets/{select-CjpI0JtN.js → select-CD38f1WQ.js} +2 -2
- package/public/assets/{select-CjpI0JtN.js.map → select-CD38f1WQ.js.map} +1 -1
- package/public/assets/settings-T3B40Amb.js +6 -0
- package/public/assets/settings-T3B40Amb.js.map +1 -0
- package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
- package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
- package/public/assets/{tasks-BcHWptn4.js → tasks-BkrhWMw7.js} +2 -2
- package/public/assets/{tasks-BcHWptn4.js.map → tasks-BkrhWMw7.js.map} +1 -1
- package/public/assets/{teams-BGL-VpqZ.js → teams-DyzaREAg.js} +2 -2
- package/public/assets/{teams-BGL-VpqZ.js.map → teams-DyzaREAg.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
- package/public/assets/user-avatar-C7i11_ju.js +2 -0
- package/public/assets/{user-avatar-DZ1g4X7l.js.map → user-avatar-C7i11_ju.js.map} +1 -1
- package/public/assets/{work-queue-CIy6fe6y.js → work-queue-DHp4p1Zk.js} +2 -2
- package/public/assets/{work-queue-CIy6fe6y.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
- package/public/assets/workspaces-BzwZvbsq.js +3 -0
- package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-lifecycle-events.ts +0 -10
- package/src/branch-landed.ts +17 -29
- package/src/config-store.ts +13 -26
- package/src/context-advisory.ts +20 -10
- package/src/db/migrations.ts +17 -0
- package/src/db/scheduled-timers.ts +33 -12
- package/src/db/schema.ts +4 -0
- package/src/mcp/index.ts +1 -1
- package/src/mcp/tools-messaging.ts +28 -10
- package/src/mcp/tools-scheduler.ts +10 -4
- package/src/mcp-notification-subscription-tools.ts +55 -2
- package/src/notification-router.ts +163 -9
- package/src/notification-rules.ts +7 -1
- package/src/notification-settings-store.ts +47 -0
- package/src/notification-settings.ts +155 -0
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +0 -3
- package/src/routes/index.ts +2 -0
- package/src/routes/notifications.ts +36 -0
- package/src/routes/settings.ts +6 -0
- package/src/security.ts +1 -0
- package/src/services/scheduler.ts +29 -1
- package/src/settings/registry.ts +30 -44
- package/src/sse.ts +1 -0
- package/public/assets/MessageBubble-CUddRP1E.js +0 -3
- package/public/assets/MessageBubble-CUddRP1E.js.map +0 -1
- package/public/assets/activity-B6WPWNsX.js +0 -2
- package/public/assets/agents-BebF-yQr.js +0 -2
- package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
- package/public/assets/card-DDxxo-Fo.js +0 -2
- package/public/assets/chat-DfqC3kFF.js +0 -2
- package/public/assets/copy-button-D3B_s7J7.js +0 -2
- package/public/assets/dist-CIbvPDQV.js +0 -2
- package/public/assets/dist-CoRABSO3.js +0 -2
- package/public/assets/dist-DyqEeyBI.js +0 -2
- package/public/assets/index-CdDX5NR1.js +0 -23
- package/public/assets/index-CdDX5NR1.js.map +0 -1
- package/public/assets/index-umexz3cs.css +0 -2
- package/public/assets/integrations-D9Cx-7ma.js +0 -2
- package/public/assets/messages-B8jF9p2l.js +0 -2
- package/public/assets/settings-CMgqpUY-.js +0 -4
- package/public/assets/settings-CMgqpUY-.js.map +0 -1
- package/public/assets/user-avatar-DZ1g4X7l.js +0 -2
- package/public/assets/workspaces-C740m1ys.js +0 -3
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { notifySystemMessage } from "./notify";
|
|
2
|
-
import
|
|
2
|
+
import { createActivityEvent, getAgent, getMessage } from "./db";
|
|
3
|
+
import { subscriptionDecisionFor } from "./db/notification-subscriptions";
|
|
4
|
+
import { notificationEnabledFor } from "./notification-settings-store";
|
|
5
|
+
import type { Message, MessageKind, NotificationRoutingExplanation, NotificationRoutingPreview, NotificationRoutingRecipientInput } from "./types";
|
|
3
6
|
import { DEFAULT_TTL_MS } from "./notification-types";
|
|
4
7
|
import type { NotificationScope, NotificationType } from "./notification-types";
|
|
8
|
+
import { resolveAudience } from "./notification-rules";
|
|
5
9
|
|
|
6
10
|
export {
|
|
7
11
|
DEFAULT_TTL_MS,
|
|
@@ -44,7 +48,7 @@ interface RouteNotificationInput {
|
|
|
44
48
|
* durable targets (`policy:`, `team:`, `label:`, `broadcast`, …) — the delivery layer resolves
|
|
45
49
|
* durable forms at poll time. #442 will derive this set from `scope` via the rule engine.
|
|
46
50
|
*/
|
|
47
|
-
recipients
|
|
51
|
+
recipients?: Array<string | NotificationRoutingRecipientInput | undefined | null>;
|
|
48
52
|
message: NotificationMessage;
|
|
49
53
|
/**
|
|
50
54
|
* #451 — time-to-live in ms from `emittedAt`, after which the notification is stale and must not
|
|
@@ -82,8 +86,8 @@ export function routeNotification(input: RouteNotificationInput): Message[] {
|
|
|
82
86
|
// router correct for any caller that routes a delayed/replayed event.
|
|
83
87
|
if (ttl !== null && Date.now() - emittedAt > ttl) return [];
|
|
84
88
|
|
|
85
|
-
const
|
|
86
|
-
if (recipients.length === 0) return [];
|
|
89
|
+
const preview = previewNotificationRouting(input);
|
|
90
|
+
if (preview.recipients.length === 0) return [];
|
|
87
91
|
|
|
88
92
|
// Stamp the TTL on the message so the delivery/poll path (pollMessages) enforces it for offline
|
|
89
93
|
// or late-joining recipients too. An explicit `null` (never-expire) is authoritative: it suppresses
|
|
@@ -91,16 +95,166 @@ export function routeNotification(input: RouteNotificationInput): Message[] {
|
|
|
91
95
|
// never-expire intent instead of inheriting the moderate baseline.
|
|
92
96
|
const maxAgeSeconds = ttl === null ? null : Math.max(1, Math.ceil(ttl / 1000));
|
|
93
97
|
|
|
94
|
-
return recipients.map((
|
|
95
|
-
notifySystemMessage(
|
|
98
|
+
return preview.recipients.map((routing) => {
|
|
99
|
+
const message = notifySystemMessage(routing.recipient, {
|
|
96
100
|
subject: input.message.subject,
|
|
97
101
|
body: input.message.body,
|
|
98
|
-
payload: input.message.payload,
|
|
102
|
+
payload: { ...(input.message.payload ?? {}), routing: compactRoutingExplanation(routing) },
|
|
99
103
|
idempotencyKey: input.message.idempotencyKey,
|
|
100
104
|
kind: input.message.kind,
|
|
101
105
|
from: input.message.from,
|
|
102
106
|
replyExpected: input.message.replyExpected,
|
|
103
107
|
maxAgeSeconds,
|
|
104
|
-
})
|
|
105
|
-
|
|
108
|
+
});
|
|
109
|
+
auditRoutingTrace(message, preview, maxAgeSeconds);
|
|
110
|
+
return message;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function previewNotificationRouting(input: Pick<RouteNotificationInput, "type" | "scope" | "recipients">): NotificationRoutingPreview {
|
|
115
|
+
const candidates = normalizeRoutingRecipients(input);
|
|
116
|
+
const recipients: NotificationRoutingExplanation[] = [];
|
|
117
|
+
const suppressed: NotificationRoutingExplanation[] = [];
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
const settingsEnabled = notificationRecipientEnabled(input.type, candidate.agentId, input.scope);
|
|
120
|
+
const subscriptionState = subscriptionStateFor(input.type, candidate.agentId, input.scope);
|
|
121
|
+
const matchedRule = candidate.matchedRule || inferMatchedRule(candidate.agentId, input.scope);
|
|
122
|
+
const explanation: NotificationRoutingExplanation = {
|
|
123
|
+
type: input.type,
|
|
124
|
+
recipient: candidate.agentId,
|
|
125
|
+
matchedRule,
|
|
126
|
+
reason: candidate.reason || routingReason(matchedRule, candidate.agentId, input.scope, subscriptionState, settingsEnabled),
|
|
127
|
+
scope: input.scope as Record<string, unknown>,
|
|
128
|
+
subscriptionState,
|
|
129
|
+
settingsEnabled,
|
|
130
|
+
};
|
|
131
|
+
if (settingsEnabled) recipients.push(explanation);
|
|
132
|
+
else suppressed.push({ ...explanation, matchedRule: "settings:notifications" });
|
|
133
|
+
}
|
|
134
|
+
return { type: input.type, scope: input.scope as Record<string, unknown>, recipients, suppressed };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function whyNotified(messageId: number, agentId: string): (NotificationRoutingExplanation & { messageId: number; agentId: string }) | null {
|
|
138
|
+
const message = getMessage(messageId);
|
|
139
|
+
if (!message) return null;
|
|
140
|
+
const routing = routingExplanationFromPayload(message.payload?.routing);
|
|
141
|
+
if (!routing) return null;
|
|
142
|
+
if (routing.recipient !== agentId && message.to !== agentId && message.resolvedToAgent !== agentId) return null;
|
|
143
|
+
return { ...routing, messageId, agentId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function notificationRecipientEnabled(type: NotificationType, recipient: string, scope: NotificationScope): boolean {
|
|
147
|
+
const agent = getAgent(recipient);
|
|
148
|
+
return notificationEnabledFor(type, {
|
|
149
|
+
agentId: agent?.id ?? recipient,
|
|
150
|
+
teamId: agent?.teamId ?? scope.teamId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeRoutingRecipients(input: Pick<RouteNotificationInput, "type" | "scope" | "recipients">): NotificationRoutingRecipientInput[] {
|
|
155
|
+
const raw = input.recipients === undefined
|
|
156
|
+
? resolveAudience(input.type, input.scope).map((member) => ({ agentId: member.agentId, matchedRule: member.matchedRule }))
|
|
157
|
+
: input.recipients;
|
|
158
|
+
const byAgent = new Map<string, NotificationRoutingRecipientInput>();
|
|
159
|
+
for (const item of raw) {
|
|
160
|
+
const candidate = normalizeRoutingRecipient(item);
|
|
161
|
+
if (!candidate || byAgent.has(candidate.agentId)) continue;
|
|
162
|
+
byAgent.set(candidate.agentId, candidate);
|
|
163
|
+
}
|
|
164
|
+
return [...byAgent.values()];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeRoutingRecipient(value: string | NotificationRoutingRecipientInput | null | undefined): NotificationRoutingRecipientInput | null {
|
|
168
|
+
if (typeof value === "string") {
|
|
169
|
+
const agentId = value.trim();
|
|
170
|
+
return agentId ? { agentId } : null;
|
|
171
|
+
}
|
|
172
|
+
if (!value) return null;
|
|
173
|
+
const agentId = typeof value.agentId === "string" ? value.agentId.trim() : "";
|
|
174
|
+
if (!agentId) return null;
|
|
175
|
+
return {
|
|
176
|
+
agentId,
|
|
177
|
+
matchedRule: typeof value.matchedRule === "string" && value.matchedRule.trim() ? value.matchedRule.trim() : undefined,
|
|
178
|
+
reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inferMatchedRule(recipient: string, scope: NotificationScope): string {
|
|
183
|
+
if (scope.author && recipient === scope.author) return "author";
|
|
184
|
+
if (scope.parent && recipient === scope.parent) return "spawn-parent";
|
|
185
|
+
if (scope.agentId && recipient === scope.agentId) return "subject-agent";
|
|
186
|
+
const agent = getAgent(recipient);
|
|
187
|
+
if (scope.teamId && agent?.teamId === scope.teamId) return "same-team";
|
|
188
|
+
return "explicit-recipient";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function subscriptionStateFor(type: NotificationType, recipient: string, scope: NotificationScope): NotificationRoutingExplanation["subscriptionState"] {
|
|
192
|
+
const agent = getAgent(recipient);
|
|
193
|
+
if (!agent) return "unknown";
|
|
194
|
+
return subscriptionDecisionFor(agent, { type, scope, now: Date.now() });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function routingReason(
|
|
198
|
+
matchedRule: string,
|
|
199
|
+
recipient: string,
|
|
200
|
+
scope: NotificationScope,
|
|
201
|
+
subscriptionState: NotificationRoutingExplanation["subscriptionState"],
|
|
202
|
+
settingsEnabled: boolean,
|
|
203
|
+
): string {
|
|
204
|
+
if (!settingsEnabled) return "notifications are disabled for this recipient by settings";
|
|
205
|
+
if (matchedRule === "author") return "you are the author";
|
|
206
|
+
if (matchedRule === "spawn-parent") return "you are the spawn parent";
|
|
207
|
+
if (matchedRule === "subject-agent") return "you are the notification subject";
|
|
208
|
+
if (matchedRule === "same-team") return scope.teamId ? `same team ${scope.teamId}` : "same team";
|
|
209
|
+
if (matchedRule === "on-repo-root") return scope.repoRoot ? `online in repo root ${scope.repoRoot}` : "online in the repo root";
|
|
210
|
+
if (matchedRule === "subscription:store" || subscriptionState === "deliver") return "explicit subscription matched";
|
|
211
|
+
if (matchedRule === "settings:notifications") return "notifications are disabled for this recipient by settings";
|
|
212
|
+
return `explicit recipient ${recipient}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function compactRoutingExplanation(routing: NotificationRoutingExplanation): NotificationRoutingExplanation {
|
|
216
|
+
return routing;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function routingExplanationFromPayload(value: unknown): NotificationRoutingExplanation | null {
|
|
220
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
221
|
+
const record = value as Record<string, unknown>;
|
|
222
|
+
if (typeof record.type !== "string" || typeof record.recipient !== "string" || typeof record.matchedRule !== "string") return null;
|
|
223
|
+
return {
|
|
224
|
+
type: record.type as NotificationType,
|
|
225
|
+
recipient: record.recipient,
|
|
226
|
+
matchedRule: record.matchedRule,
|
|
227
|
+
reason: typeof record.reason === "string" ? record.reason : "",
|
|
228
|
+
scope: record.scope && typeof record.scope === "object" && !Array.isArray(record.scope) ? record.scope as Record<string, unknown> : {},
|
|
229
|
+
subscriptionState: record.subscriptionState === "deliver" || record.subscriptionState === "suppress" || record.subscriptionState === "abstain"
|
|
230
|
+
? record.subscriptionState
|
|
231
|
+
: "unknown",
|
|
232
|
+
settingsEnabled: record.settingsEnabled !== false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function auditRoutingTrace(message: Message, preview: NotificationRoutingPreview, maxAgeSeconds: number | null): void {
|
|
237
|
+
try {
|
|
238
|
+
createActivityEvent({
|
|
239
|
+
kind: "state",
|
|
240
|
+
title: "Notification routed",
|
|
241
|
+
body: preview.recipients.find((item) => item.recipient === message.to)?.reason,
|
|
242
|
+
icon: "ti-route",
|
|
243
|
+
view: "messages",
|
|
244
|
+
messageId: message.id,
|
|
245
|
+
agentId: message.to,
|
|
246
|
+
metadata: {
|
|
247
|
+
source: "notification-router",
|
|
248
|
+
notificationType: preview.type,
|
|
249
|
+
scope: preview.scope as Record<string, unknown>,
|
|
250
|
+
routing: {
|
|
251
|
+
delivered: preview.recipients,
|
|
252
|
+
suppressed: preview.suppressed,
|
|
253
|
+
maxAgeSeconds,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
// Routing audit must never block notification delivery.
|
|
259
|
+
}
|
|
106
260
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentCard } from "./types";
|
|
2
2
|
import { listAgents } from "./db";
|
|
3
3
|
import { isAgentOnline } from "./agent-ref";
|
|
4
|
+
import { notificationEnabledFor } from "./notification-settings-store";
|
|
4
5
|
import type { NotificationType, NotificationScope } from "./notification-types";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -154,6 +155,11 @@ export function registerDefaultAudienceRules(type: NotificationType, rules: Noti
|
|
|
154
155
|
// #443 subscription seam: opt-in / opt-out / mute become override rules injected here. They run AFTER
|
|
155
156
|
// a type's default rules so they win the last-wins fold. Empty until #443 wires a subscription store.
|
|
156
157
|
let subscriptionRules: NotificationRule[] = [];
|
|
158
|
+
const settingsRule: NotificationRule = {
|
|
159
|
+
name: "settings:notifications",
|
|
160
|
+
evaluate: (agent, { type, scope }) =>
|
|
161
|
+
notificationEnabledFor(type, { agentId: agent.id, teamId: agent.teamId ?? scope.teamId }) ? "abstain" : "suppress",
|
|
162
|
+
};
|
|
157
163
|
|
|
158
164
|
/** #443 hook: install the subscription override rules (opt-in/opt-out/mute). Pass [] to clear. */
|
|
159
165
|
export function setSubscriptionRules(rules: NotificationRule[]): void {
|
|
@@ -164,7 +170,7 @@ export function setSubscriptionRules(rules: NotificationRule[]): void {
|
|
|
164
170
|
function audienceRulesFor(type: NotificationType): NotificationRule[] {
|
|
165
171
|
const defaults = audienceRegistry.get(type);
|
|
166
172
|
if (!defaults) return [];
|
|
167
|
-
return
|
|
173
|
+
return [...defaults, ...subscriptionRules, settingsRule];
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getConfig, getNotificationsConfig, listConfig } from "./config-store";
|
|
2
|
+
import {
|
|
3
|
+
NOTIFICATIONS_DEFAULT_KEY,
|
|
4
|
+
NOTIFICATIONS_NAMESPACE,
|
|
5
|
+
mergeNotificationSettings,
|
|
6
|
+
notificationAgentKey,
|
|
7
|
+
notificationTeamKey,
|
|
8
|
+
notificationTypeEnabled,
|
|
9
|
+
parseNotificationSettingsKey,
|
|
10
|
+
validateNotificationSettingsLayer,
|
|
11
|
+
type NotificationSettingsTarget,
|
|
12
|
+
type NotificationSettingLayer,
|
|
13
|
+
} from "./notification-settings";
|
|
14
|
+
import { registerNotificationSettingDescriptor } from "./settings/registry";
|
|
15
|
+
import type { NotificationsConfig } from "./types";
|
|
16
|
+
import type { NotificationType } from "./notification-types";
|
|
17
|
+
|
|
18
|
+
export function ensureNotificationSettingDescriptors(extraKeys: string[] = []): void {
|
|
19
|
+
const keys = new Set<string>([NOTIFICATIONS_DEFAULT_KEY]);
|
|
20
|
+
for (const entry of listConfig(NOTIFICATIONS_NAMESPACE)) keys.add(entry.key);
|
|
21
|
+
for (const key of extraKeys) keys.add(key);
|
|
22
|
+
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
if (!parseNotificationSettingsKey(key)) continue;
|
|
25
|
+
registerNotificationSettingDescriptor(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getResolvedNotificationsConfig(target: NotificationSettingsTarget = {}): NotificationsConfig {
|
|
30
|
+
let config = getNotificationsConfig();
|
|
31
|
+
if (target.teamId) {
|
|
32
|
+
config = mergeNotificationSettings(config, notificationSettingsLayer(notificationTeamKey(target.teamId)));
|
|
33
|
+
}
|
|
34
|
+
if (target.agentId) {
|
|
35
|
+
config = mergeNotificationSettings(config, notificationSettingsLayer(notificationAgentKey(target.agentId)));
|
|
36
|
+
}
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function notificationEnabledFor(type: NotificationType, target: NotificationSettingsTarget = {}): boolean {
|
|
41
|
+
return notificationTypeEnabled(getResolvedNotificationsConfig(target), type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function notificationSettingsLayer(key: string): NotificationSettingLayer | null {
|
|
45
|
+
const entry = getConfig<NotificationSettingLayer>(NOTIFICATIONS_NAMESPACE, key);
|
|
46
|
+
return entry ? validateNotificationSettingsLayer(entry.value) : null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import type { JSONSchema } from "agent-relay-sdk";
|
|
3
|
+
import { ValidationError } from "./db/connection.ts";
|
|
4
|
+
import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
|
|
5
|
+
import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
|
|
6
|
+
import type { NotificationsConfig } from "./types";
|
|
7
|
+
import type { NotificationType } from "./notification-types";
|
|
8
|
+
|
|
9
|
+
export const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
10
|
+
export const NOTIFICATIONS_DEFAULT_KEY = "default";
|
|
11
|
+
const TEAM_KEY_PREFIX = "team:";
|
|
12
|
+
const AGENT_KEY_PREFIX = "agent:";
|
|
13
|
+
|
|
14
|
+
type NotificationToggleKey = "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed";
|
|
15
|
+
export type NotificationSettingLayer = Partial<NotificationsConfig>;
|
|
16
|
+
|
|
17
|
+
export interface NotificationSettingsTarget {
|
|
18
|
+
agentId?: string;
|
|
19
|
+
teamId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
|
|
23
|
+
enabled: true,
|
|
24
|
+
branchLanded: true,
|
|
25
|
+
agentReady: true,
|
|
26
|
+
agentExited: true,
|
|
27
|
+
agentSpawnFailed: true,
|
|
28
|
+
contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG,
|
|
29
|
+
quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const CONTEXT_THRESHOLD_SETTINGS_SCHEMA: JSONSchema = {
|
|
33
|
+
type: "object",
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: {
|
|
36
|
+
enabled: { type: "boolean" },
|
|
37
|
+
advisoryUtilization: { type: "number", minimum: 0, maximum: 1 },
|
|
38
|
+
selfCompactUtilization: { type: "number", minimum: 0, maximum: 1 },
|
|
39
|
+
warnUtilization: { type: "number", minimum: 0, maximum: 1 },
|
|
40
|
+
hardBackstopUtilization: { type: "number", minimum: 0, maximum: 1 },
|
|
41
|
+
bandSize: { type: "number", exclusiveMinimum: 0, maximum: 1 },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const QUOTA_THRESHOLD_SETTINGS_SCHEMA: JSONSchema = {
|
|
46
|
+
type: "object",
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
properties: {
|
|
49
|
+
enabled: { type: "boolean" },
|
|
50
|
+
warnRemaining: { type: "number", minimum: 0, maximum: 1 },
|
|
51
|
+
criticalRemaining: { type: "number", minimum: 0, maximum: 1 },
|
|
52
|
+
resetRemaining: { type: "number", minimum: 0, maximum: 1 },
|
|
53
|
+
bandSize: { type: "number", exclusiveMinimum: 0, maximum: 1 },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const NOTIFICATIONS_SETTINGS_SCHEMA: JSONSchema = {
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
enabled: { type: "boolean" },
|
|
62
|
+
branchLanded: { type: "boolean" },
|
|
63
|
+
agentReady: { type: "boolean" },
|
|
64
|
+
agentExited: { type: "boolean" },
|
|
65
|
+
agentSpawnFailed: { type: "boolean" },
|
|
66
|
+
contextThreshold: CONTEXT_THRESHOLD_SETTINGS_SCHEMA,
|
|
67
|
+
quotaThreshold: QUOTA_THRESHOLD_SETTINGS_SCHEMA,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function notificationTeamKey(teamId: string): string {
|
|
72
|
+
return `${TEAM_KEY_PREFIX}${cleanLayerId(teamId, "teamId")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function notificationAgentKey(agentId: string): string {
|
|
76
|
+
return `${AGENT_KEY_PREFIX}${cleanLayerId(agentId, "agentId")}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseNotificationSettingsKey(key: string): { kind: "default" | "team" | "agent"; id?: string } | null {
|
|
80
|
+
if (key === NOTIFICATIONS_DEFAULT_KEY) return { kind: "default" };
|
|
81
|
+
if (key.startsWith(TEAM_KEY_PREFIX)) {
|
|
82
|
+
const id = key.slice(TEAM_KEY_PREFIX.length).trim();
|
|
83
|
+
return id ? { kind: "team", id } : null;
|
|
84
|
+
}
|
|
85
|
+
if (key.startsWith(AGENT_KEY_PREFIX)) {
|
|
86
|
+
const id = key.slice(AGENT_KEY_PREFIX.length).trim();
|
|
87
|
+
return id ? { kind: "agent", id } : null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
93
|
+
return mergeNotificationSettings(NOTIFICATIONS_CONFIG_DEFAULTS, validateNotificationSettingsLayer(value));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateNotificationSettingsLayer(value: unknown): NotificationSettingLayer {
|
|
97
|
+
if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
|
|
98
|
+
const bool = (key: "enabled" | NotificationToggleKey): boolean | undefined =>
|
|
99
|
+
value[key] === undefined ? undefined : cleanBoolean(value[key], key);
|
|
100
|
+
return {
|
|
101
|
+
...(bool("enabled") !== undefined ? { enabled: bool("enabled")! } : {}),
|
|
102
|
+
...(bool("branchLanded") !== undefined ? { branchLanded: bool("branchLanded")! } : {}),
|
|
103
|
+
...(bool("agentReady") !== undefined ? { agentReady: bool("agentReady")! } : {}),
|
|
104
|
+
...(bool("agentExited") !== undefined ? { agentExited: bool("agentExited")! } : {}),
|
|
105
|
+
...(bool("agentSpawnFailed") !== undefined ? { agentSpawnFailed: bool("agentSpawnFailed")! } : {}),
|
|
106
|
+
...(value.contextThreshold === undefined ? {} : { contextThreshold: validateContextThresholdConfig(value.contextThreshold) }),
|
|
107
|
+
...(value.quotaThreshold === undefined ? {} : { quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold) }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mergeNotificationSettings(base: NotificationsConfig, layer: NotificationSettingLayer | null | undefined): NotificationsConfig {
|
|
112
|
+
if (!layer) return base;
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
...withoutNestedThresholds(layer),
|
|
116
|
+
contextThreshold: layer.contextThreshold ? { ...base.contextThreshold, ...layer.contextThreshold } : base.contextThreshold,
|
|
117
|
+
quotaThreshold: layer.quotaThreshold ? { ...base.quotaThreshold, ...layer.quotaThreshold } : base.quotaThreshold,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function notificationTypeEnabled(config: NotificationsConfig, type: NotificationType): boolean {
|
|
122
|
+
if (!config.enabled) return false;
|
|
123
|
+
if (type === "agent.context_threshold") return config.contextThreshold.enabled;
|
|
124
|
+
if (type === "agent.quota_threshold") return config.quotaThreshold.enabled;
|
|
125
|
+
const key = notificationToggleForType(type);
|
|
126
|
+
return key ? config[key] : true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function notificationToggleForType(type: NotificationType): NotificationToggleKey | null {
|
|
130
|
+
if (type === "branch.landed" || type === "landed-failure") return "branchLanded";
|
|
131
|
+
if (type === "agent.ready") return "agentReady";
|
|
132
|
+
if (type === "agent.exited") return "agentExited";
|
|
133
|
+
if (type === "agent.spawn_failed") return "agentSpawnFailed";
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function withoutNestedThresholds(layer: NotificationSettingLayer): Omit<NotificationSettingLayer, "contextThreshold" | "quotaThreshold"> {
|
|
138
|
+
const { contextThreshold: _contextThreshold, quotaThreshold: _quotaThreshold, ...rest } = layer;
|
|
139
|
+
return rest;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cleanBoolean(value: unknown, field: string): boolean {
|
|
143
|
+
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cleanLayerId(value: string, field: string): string {
|
|
148
|
+
const clean = value.trim();
|
|
149
|
+
if (!clean) throw new ValidationError(`${field} required`);
|
|
150
|
+
if (clean.length > 200) throw new ValidationError(`${field} must be at most 200 characters`);
|
|
151
|
+
if (clean.includes("/") || clean.includes("\n") || clean.includes("\r")) {
|
|
152
|
+
throw new ValidationError(`${field} must not contain slashes or newlines`);
|
|
153
|
+
}
|
|
154
|
+
return clean;
|
|
155
|
+
}
|
|
@@ -84,5 +84,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
84
84
|
"workspace.review_changes_requested": null,
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
export const NOTIFICATION_TYPES = Object.keys(DEFAULT_TTL_MS) as NotificationType[];
|
|
88
|
+
|
|
87
89
|
/** Convenience TTL for emitter-supplied overrides on bystander/informational notifications. */
|
|
88
90
|
export const INFORMATIONAL_TTL_MS = 30 * MINUTES;
|
package/src/quota-advisory.ts
CHANGED
|
@@ -165,9 +165,6 @@ function notifyQuotaAdvisory(input: {
|
|
|
165
165
|
data: payload,
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
const notifications = effectiveQuotaNotificationConfig();
|
|
169
|
-
if (!notifications.enabled || !notifications.quotaThreshold.enabled) return;
|
|
170
|
-
|
|
171
168
|
routeNotification({
|
|
172
169
|
type: "agent.quota_threshold",
|
|
173
170
|
scope: {},
|
package/src/routes/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { deleteIssueRefRoute, getIssueRefRoute, listIssueRefsRoute, patchIssueRe
|
|
|
40
40
|
import { getProjectEntriesRoute, getProjectEntryRoute, getProjectResolveRoute, getProjectRoute, getProjectsRoute } from "./projects";
|
|
41
41
|
import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
|
|
42
42
|
import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
|
|
43
|
+
import { postNotificationPreviewRoute } from "./notifications";
|
|
43
44
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
44
45
|
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
|
|
45
46
|
import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
@@ -246,6 +247,7 @@ const routes: Route[] = [
|
|
|
246
247
|
route("GET", "/api/settings/:namespace/:key", getSettingRoute),
|
|
247
248
|
route("PUT", "/api/settings/:namespace", putSettingsDefaultRoute),
|
|
248
249
|
route("PUT", "/api/settings/:namespace/:key", putSettingRoute),
|
|
250
|
+
route("POST", "/api/notifications/preview", postNotificationPreviewRoute),
|
|
249
251
|
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
250
252
|
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
251
253
|
route("GET", "/api/config/:namespace/:key", getConfigKey),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Domain: notification routing preview/explainability (#446).
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { ValidationError } from "../db";
|
|
4
|
+
import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "../notification-types";
|
|
5
|
+
import { previewNotificationRouting } from "../notification-router";
|
|
6
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
7
|
+
|
|
8
|
+
export const postNotificationPreviewRoute: Handler = async (req) => {
|
|
9
|
+
const parsed = await parseBody<unknown>(req);
|
|
10
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
11
|
+
try {
|
|
12
|
+
if (!isRecord(parsed.body)) return error("JSON object body required");
|
|
13
|
+
return json({
|
|
14
|
+
preview: previewNotificationRouting({
|
|
15
|
+
type: cleanNotificationType(parsed.body.type),
|
|
16
|
+
scope: cleanScope(parsed.body.scope),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function cleanNotificationType(value: unknown): NotificationType {
|
|
26
|
+
if (typeof value !== "string" || !NOTIFICATION_TYPES.includes(value as NotificationType)) {
|
|
27
|
+
throw new ValidationError(`type must be one of: ${NOTIFICATION_TYPES.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return value as NotificationType;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cleanScope(value: unknown): NotificationScope {
|
|
33
|
+
if (value === undefined || value === null) return {};
|
|
34
|
+
if (!isRecord(value)) throw new ValidationError("scope must be an object");
|
|
35
|
+
return value as NotificationScope;
|
|
36
|
+
}
|
package/src/routes/settings.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { ValidationError } from "../db";
|
|
|
3
3
|
import { getConfig, setConfig } from "../config-store";
|
|
4
4
|
import { getUserSetting, setUserSetting } from "../user-settings-store";
|
|
5
5
|
import { ensureProviderConfigDescriptors, migrateLocalProviderConfigs } from "../provider-config-store";
|
|
6
|
+
import { ensureNotificationSettingDescriptors } from "../notification-settings-store";
|
|
7
|
+
import { NOTIFICATIONS_NAMESPACE } from "../notification-settings";
|
|
6
8
|
import { getSettingDescriptor, listSettingDescriptors, validateSettingValue, PROVIDER_CONFIG_NAMESPACE } from "../settings/registry";
|
|
7
9
|
import { isRequestAuthorizedFor } from "../security";
|
|
8
10
|
import { cleanString } from "../validation";
|
|
@@ -21,6 +23,7 @@ type SettingEnvelope = SettingEntry;
|
|
|
21
23
|
export const getSettingsRoute: Handler = (req) => {
|
|
22
24
|
migrateLocalProviderConfigs();
|
|
23
25
|
ensureProviderConfigDescriptors();
|
|
26
|
+
ensureNotificationSettingDescriptors();
|
|
24
27
|
return json({ settings: listVisibleSettings(req) });
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -28,6 +31,7 @@ export const getSettingsNamespaceRoute: Handler = (req, params) => {
|
|
|
28
31
|
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
29
32
|
migrateLocalProviderConfigs();
|
|
30
33
|
ensureProviderConfigDescriptors();
|
|
34
|
+
if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors();
|
|
31
35
|
return json({ settings: listVisibleSettings(req).filter((setting) => setting.namespace === namespace) });
|
|
32
36
|
};
|
|
33
37
|
|
|
@@ -36,6 +40,7 @@ export const getSettingRoute: Handler = (req, params) => {
|
|
|
36
40
|
const key = normalizeConfigPathParam(params.key, "key");
|
|
37
41
|
migrateLocalProviderConfigs();
|
|
38
42
|
ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
|
|
43
|
+
if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors([key]);
|
|
39
44
|
const descriptor = getSettingDescriptor(namespace, key);
|
|
40
45
|
if (!descriptor) return error("setting not found", 404);
|
|
41
46
|
const readable = canAccessSetting(req, descriptor.scope.read);
|
|
@@ -76,6 +81,7 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
|
|
|
76
81
|
try {
|
|
77
82
|
migrateLocalProviderConfigs();
|
|
78
83
|
ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
|
|
84
|
+
if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors([key]);
|
|
79
85
|
const descriptor = getSettingDescriptor(namespace, key);
|
|
80
86
|
if (!descriptor) return error("setting not found", 404);
|
|
81
87
|
if (descriptor.tier !== "server-runtime" && descriptor.tier !== "user") return error("setting is read-only", 403);
|
package/src/security.ts
CHANGED
|
@@ -151,6 +151,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
151
151
|
if (pathname === "/api/health") return "health:read";
|
|
152
152
|
if (pathname === "/api/events") return "events:read";
|
|
153
153
|
if (pathname === "/api/route") return "agent:read";
|
|
154
|
+
if (pathname === "/api/notifications/preview") return "notifications:read";
|
|
154
155
|
if (pathname === "/api/mcp") return "mcp:use";
|
|
155
156
|
if (pathname === "/api/orchestrators/bootstrap/exchange") return "token:write";
|
|
156
157
|
if (pathname === "/api/orchestrators/bootstrap") return "token:write";
|
|
@@ -39,9 +39,13 @@ export interface ScheduleTimerInput {
|
|
|
39
39
|
everyMs?: number;
|
|
40
40
|
rrule?: string;
|
|
41
41
|
timezone?: string;
|
|
42
|
+
kind?: SendMessageInput["kind"];
|
|
43
|
+
channel?: string;
|
|
42
44
|
subject?: string;
|
|
43
45
|
body: string;
|
|
46
|
+
claimable?: boolean;
|
|
44
47
|
payload?: Record<string, unknown>;
|
|
48
|
+
meta?: Record<string, unknown>;
|
|
45
49
|
maxRuns?: number;
|
|
46
50
|
expiresAt?: string | number;
|
|
47
51
|
idempotencyKey?: string;
|
|
@@ -78,9 +82,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
|
|
|
78
82
|
const deliveryFrom = ctx.callerAgentId ?? "system";
|
|
79
83
|
const body = cleanBody(input.body);
|
|
80
84
|
const payload = cleanPayload(input.payload);
|
|
85
|
+
const meta = cleanPayload(input.meta);
|
|
81
86
|
const maxRuns = cleanPositiveInt(input.maxRuns, "maxRuns");
|
|
82
87
|
const expiresAt = cleanExpiresAt(input.expiresAt, now);
|
|
83
88
|
const idempotencyKey = cleanOptionalString(input.idempotencyKey, "idempotencyKey", 240);
|
|
89
|
+
const messageKind = cleanOptionalString(input.kind, "kind", 40) as SendMessageInput["kind"] | undefined;
|
|
90
|
+
const channel = cleanOptionalString(input.channel, "channel", 120);
|
|
91
|
+
const claimable = cleanOptionalBoolean(input.claimable, "claimable");
|
|
84
92
|
|
|
85
93
|
if (schedule.kind === "interval") {
|
|
86
94
|
if ((schedule.def.everyMs ?? 0) < SCHEDULER_MIN_RECURRING_INTERVAL_MS) {
|
|
@@ -100,9 +108,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
|
|
|
100
108
|
validateSendMessageAuthorization({
|
|
101
109
|
from: deliveryFrom,
|
|
102
110
|
to: target,
|
|
111
|
+
kind: messageKind,
|
|
112
|
+
channel,
|
|
103
113
|
body,
|
|
104
114
|
subject: cleanOptionalString(input.subject, "subject", 200),
|
|
115
|
+
claimable,
|
|
105
116
|
payload,
|
|
117
|
+
meta,
|
|
106
118
|
idempotencyKey: idempotencyKey ? `${idempotencyKey}:probe` : undefined,
|
|
107
119
|
}, ctx);
|
|
108
120
|
|
|
@@ -116,9 +128,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
|
|
|
116
128
|
nextRunAt: schedule.nextRunAt,
|
|
117
129
|
scheduleKind: schedule.kind,
|
|
118
130
|
scheduleDef: schedule.def,
|
|
131
|
+
messageKind,
|
|
132
|
+
channel,
|
|
119
133
|
subject: cleanOptionalString(input.subject, "subject", 200),
|
|
120
134
|
body,
|
|
135
|
+
claimable,
|
|
121
136
|
payload,
|
|
137
|
+
meta,
|
|
122
138
|
maxRuns,
|
|
123
139
|
expiresAt,
|
|
124
140
|
idempotencyKey,
|
|
@@ -176,8 +192,11 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
|
|
|
176
192
|
const result = sendMessageService({
|
|
177
193
|
from: timer.deliveryFrom,
|
|
178
194
|
to: timer.target,
|
|
195
|
+
kind: timer.messageKind as SendMessageInput["kind"] | undefined,
|
|
196
|
+
channel: timer.channel,
|
|
179
197
|
subject: timer.subject,
|
|
180
198
|
body: timer.body,
|
|
199
|
+
claimable: timer.claimable,
|
|
181
200
|
payload: {
|
|
182
201
|
...timer.payload,
|
|
183
202
|
scheduler: {
|
|
@@ -187,6 +206,7 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
|
|
|
187
206
|
},
|
|
188
207
|
},
|
|
189
208
|
meta: {
|
|
209
|
+
...timer.meta,
|
|
190
210
|
scheduler: {
|
|
191
211
|
timerId: timer.id,
|
|
192
212
|
correlationId: timer.correlationId,
|
|
@@ -199,7 +219,7 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
|
|
|
199
219
|
return { status: "fired", messageId: result.message.id };
|
|
200
220
|
} catch (error) {
|
|
201
221
|
const updated = recordScheduledTimerFailure({
|
|
202
|
-
|
|
222
|
+
timer,
|
|
203
223
|
error: errMessage(error),
|
|
204
224
|
now,
|
|
205
225
|
retryMs: SCHEDULER_RETRY_MS,
|
|
@@ -215,6 +235,8 @@ function timerRunIdempotencyKey(timer: ScheduledTimer, runNumber: number): strin
|
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
function persistedAuthContext(timer: ScheduledTimer): AuthContext {
|
|
238
|
+
// Timers intentionally fire with the create-time component scope snapshot. That keeps durable
|
|
239
|
+
// sends restart-safe; a stricter future model can revalidate a live token at fire time.
|
|
218
240
|
const component: ComponentToken = {
|
|
219
241
|
sub: timer.createdBy,
|
|
220
242
|
role: timer.createdByKind,
|
|
@@ -287,6 +309,12 @@ function cleanPositiveInt(value: unknown, field: string): number | undefined {
|
|
|
287
309
|
return value;
|
|
288
310
|
}
|
|
289
311
|
|
|
312
|
+
function cleanOptionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
313
|
+
if (value === undefined || value === null) return undefined;
|
|
314
|
+
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
290
318
|
function cleanExpiresAt(value: unknown, now: number): number | undefined {
|
|
291
319
|
if (value === undefined || value === null) return undefined;
|
|
292
320
|
const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
|