agent-relay-server 0.91.4 → 0.93.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.
Files changed (179) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
  4. package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-BwEOOqdj.js → PlanGraphPanel-BuWerKhs.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BwEOOqdj.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
  7. package/public/assets/activity-tuTEn3vn.js +2 -0
  8. package/public/assets/{activity-DD9bl5uf.js.map → activity-tuTEn3vn.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
  10. package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
  11. package/public/assets/agents-B-BSBrM5.js +2 -0
  12. package/public/assets/{agents-DJgqCIqi.js.map → agents-B-BSBrM5.js.map} +1 -1
  13. package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
  14. package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
  15. package/public/assets/{automation-Vbei8P9W.js → automation-BAQM1dBh.js} +2 -2
  16. package/public/assets/{automation-Vbei8P9W.js.map → automation-BAQM1dBh.js.map} +1 -1
  17. package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
  18. package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
  20. package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
  21. package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
  22. package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
  23. package/public/assets/card-CpLnx1JJ.js +2 -0
  24. package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
  25. package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
  26. package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
  27. package/public/assets/chat-uWKl3MpS.js +2 -0
  28. package/public/assets/{chat-Dw5xpc5W.js.map → chat-uWKl3MpS.js.map} +1 -1
  29. package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
  30. package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
  31. package/public/assets/copy-button-CKAwTG9g.js +2 -0
  32. package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
  33. package/public/assets/display-CWMHll1J.js.map +1 -1
  34. package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
  35. package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
  36. package/public/assets/dist-BZ-CpDXy.js +2 -0
  37. package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
  38. package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
  39. package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
  40. package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
  41. package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
  42. package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
  43. package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
  44. package/public/assets/dist-CEb4q1gZ.js +2 -0
  45. package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
  46. package/public/assets/dist-DBDYIgS5.js +2 -0
  47. package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
  48. package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
  49. package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
  50. package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
  51. package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
  52. package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
  53. package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
  54. package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
  55. package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
  56. package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
  57. package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-BisZ0FtO.js → formatted-body-impl-DuqZunxL.js} +2 -2
  59. package/public/assets/{formatted-body-impl-BisZ0FtO.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
  60. package/public/assets/index-DLjkMB-b.css +2 -0
  61. package/public/assets/index-dMB8klzy.js +23 -0
  62. package/public/assets/index-dMB8klzy.js.map +1 -0
  63. package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
  64. package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
  65. package/public/assets/{insights-D9ytYZyE.js → insights-C0lZ7n8D.js} +2 -2
  66. package/public/assets/{insights-D9ytYZyE.js.map → insights-C0lZ7n8D.js.map} +1 -1
  67. package/public/assets/integrations-DXh00Ss-.js +2 -0
  68. package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
  69. package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
  70. package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
  71. package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
  72. package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
  73. package/public/assets/{maintenance-BA93FvLR.js → maintenance-Dh9nzjYV.js} +2 -2
  74. package/public/assets/{maintenance-BA93FvLR.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
  75. package/public/assets/{managed-agents-DGwdEIzA.js → managed-agents-Bsveljn4.js} +4 -4
  76. package/public/assets/{managed-agents-DGwdEIzA.js.map → managed-agents-Bsveljn4.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-C7FDXM5c.js → markdown-preview-impl-CYpMFis9.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-C7FDXM5c.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
  79. package/public/assets/{memory-CHzkRJqf.js → memory-DMFQMXrp.js} +2 -2
  80. package/public/assets/{memory-CHzkRJqf.js.map → memory-DMFQMXrp.js.map} +1 -1
  81. package/public/assets/messages-Cnzc7j50.js +2 -0
  82. package/public/assets/{messages-B7CJ5tgT.js.map → messages-Cnzc7j50.js.map} +1 -1
  83. package/public/assets/{orchestrators-DOy9xIx2.js → orchestrators-DSoDPNVS.js} +2 -2
  84. package/public/assets/{orchestrators-DOy9xIx2.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
  85. package/public/assets/{overview-DLbv1BWa.js → overview-OsRxo5XQ.js} +2 -2
  86. package/public/assets/{overview-DLbv1BWa.js.map → overview-OsRxo5XQ.js.map} +1 -1
  87. package/public/assets/{pairs-BDQo-k3F.js → pairs-D4UiUhyW.js} +2 -2
  88. package/public/assets/{pairs-BDQo-k3F.js.map → pairs-D4UiUhyW.js.map} +1 -1
  89. package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
  90. package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
  91. package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
  92. package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
  93. package/public/assets/{security-9WrCcrWs.js → security-CC2F6xdd.js} +2 -2
  94. package/public/assets/{security-9WrCcrWs.js.map → security-CC2F6xdd.js.map} +1 -1
  95. package/public/assets/{select-CRjeGZ9a.js → select-CD38f1WQ.js} +2 -2
  96. package/public/assets/{select-CRjeGZ9a.js.map → select-CD38f1WQ.js.map} +1 -1
  97. package/public/assets/settings-T3B40Amb.js +6 -0
  98. package/public/assets/settings-T3B40Amb.js.map +1 -0
  99. package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
  100. package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
  101. package/public/assets/{tasks-86St2vpn.js → tasks-BkrhWMw7.js} +2 -2
  102. package/public/assets/{tasks-86St2vpn.js.map → tasks-BkrhWMw7.js.map} +1 -1
  103. package/public/assets/{teams-C9WUxzud.js → teams-DyzaREAg.js} +2 -2
  104. package/public/assets/{teams-C9WUxzud.js.map → teams-DyzaREAg.js.map} +1 -1
  105. package/public/assets/{terminal-viewer-impl-yXWyt2fm.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
  106. package/public/assets/{terminal-viewer-impl-yXWyt2fm.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
  107. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
  108. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
  109. package/public/assets/user-avatar-C7i11_ju.js +2 -0
  110. package/public/assets/{user-avatar-tomKla2H.js.map → user-avatar-C7i11_ju.js.map} +1 -1
  111. package/public/assets/{work-queue-BtLMzf0g.js → work-queue-DHp4p1Zk.js} +2 -2
  112. package/public/assets/{work-queue-BtLMzf0g.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
  113. package/public/assets/workspaces-BzwZvbsq.js +3 -0
  114. package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
  115. package/public/index.html +21 -21
  116. package/src/agent-lifecycle-events.ts +0 -10
  117. package/src/branch-landed.ts +17 -29
  118. package/src/bus.ts +9 -2
  119. package/src/config-store.ts +13 -26
  120. package/src/config.ts +1 -0
  121. package/src/context-advisory.ts +20 -10
  122. package/src/db/agent-predicates.ts +21 -0
  123. package/src/db/agents.ts +3 -3
  124. package/src/db/delivery.ts +38 -0
  125. package/src/db/messages.ts +18 -1
  126. package/src/db/migrations.ts +17 -0
  127. package/src/db/scheduled-timers.ts +33 -12
  128. package/src/db/schema.ts +4 -0
  129. package/src/db/workspaces.ts +44 -2
  130. package/src/maintenance/constants.ts +2 -0
  131. package/src/maintenance/jobs.ts +21 -8
  132. package/src/maintenance/workspaces.ts +76 -0
  133. package/src/mcp/index.ts +1 -1
  134. package/src/mcp/tools-messaging.ts +13 -3
  135. package/src/mcp/tools-spawn.ts +30 -2
  136. package/src/mcp-notification-subscription-tools.ts +55 -2
  137. package/src/notification-router.ts +163 -9
  138. package/src/notification-rules.ts +7 -1
  139. package/src/notification-settings-store.ts +47 -0
  140. package/src/notification-settings.ts +155 -0
  141. package/src/notification-types.ts +2 -0
  142. package/src/quota-advisory.ts +0 -3
  143. package/src/routes/agents-spawn.ts +15 -2
  144. package/src/routes/agents.ts +17 -2
  145. package/src/routes/commands.ts +5 -0
  146. package/src/routes/index.ts +2 -0
  147. package/src/routes/notifications.ts +36 -0
  148. package/src/routes/settings.ts +6 -0
  149. package/src/security.ts +1 -0
  150. package/src/services/managed-running.ts +18 -1
  151. package/src/services/rate-limit-resume.ts +3 -0
  152. package/src/services/register-agent.ts +7 -1
  153. package/src/services/scheduler.ts +29 -1
  154. package/src/services/spawn-agent.ts +62 -2
  155. package/src/settings/registry.ts +30 -44
  156. package/src/spawn-command.ts +10 -0
  157. package/src/sse.ts +1 -0
  158. package/src/validation.ts +7 -2
  159. package/src/workspace-auto-merge.ts +53 -9
  160. package/public/assets/MessageBubble-BORB9AKu.js +0 -3
  161. package/public/assets/MessageBubble-BORB9AKu.js.map +0 -1
  162. package/public/assets/activity-DD9bl5uf.js +0 -2
  163. package/public/assets/agents-DJgqCIqi.js +0 -2
  164. package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
  165. package/public/assets/card-DDxxo-Fo.js +0 -2
  166. package/public/assets/chat-Dw5xpc5W.js +0 -2
  167. package/public/assets/copy-button-D3B_s7J7.js +0 -2
  168. package/public/assets/dist-CIbvPDQV.js +0 -2
  169. package/public/assets/dist-CoRABSO3.js +0 -2
  170. package/public/assets/dist-DyqEeyBI.js +0 -2
  171. package/public/assets/index-Bsi2NH0v.js +0 -23
  172. package/public/assets/index-Bsi2NH0v.js.map +0 -1
  173. package/public/assets/index-umexz3cs.css +0 -2
  174. package/public/assets/integrations-D9Cx-7ma.js +0 -2
  175. package/public/assets/messages-B7CJ5tgT.js +0 -2
  176. package/public/assets/settings-Durch6xW.js +0 -4
  177. package/public/assets/settings-Durch6xW.js.map +0 -1
  178. package/public/assets/user-avatar-tomKla2H.js +0 -2
  179. package/public/assets/workspaces-C740m1ys.js +0 -3
@@ -1,7 +1,11 @@
1
1
  import { notifySystemMessage } from "./notify";
2
- import type { Message, MessageKind } from "./types";
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: Array<string | undefined | null>;
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 recipients = [...new Set(input.recipients.filter((r): r is string => Boolean(r && r.trim())))];
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((to) =>
95
- notifySystemMessage(to, {
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 subscriptionRules.length === 0 ? defaults : [...defaults, ...subscriptionRules];
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;
@@ -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: {},
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -15,10 +15,19 @@ import { authContextFromRequest } from "../services/auth-context";
15
15
  import { listHostDirectories } from "../agent-spawn";
16
16
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
17
17
  import { quotaStateFromUnknown } from "../quota";
18
- import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceMode } from "../types";
18
+ import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
19
19
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
20
20
 
21
21
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
22
+ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
23
+
24
+ function parseResumeWorkspaceHttp(value: unknown): { branch: string; mode: "attach" | "branch-from" } | undefined {
25
+ if (!value || !isRecord(value)) return undefined;
26
+ const branch = cleanString(value.branch, "resumeWorkspace.branch", { max: 500 });
27
+ const mode = value.mode === "attach" || value.mode === "branch-from" ? value.mode : undefined;
28
+ if (!branch || !mode) return undefined;
29
+ return { branch, mode };
30
+ }
22
31
 
23
32
  function normalizeAgentInput(body: unknown): RegisterAgentInput {
24
33
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
@@ -129,12 +138,16 @@ export const postAgentSpawn: Handler = async (req) => {
129
138
  projectSkills: parseProjectSkills(parsed.body.projectSkills, (m) => new ValidationError(m)),
130
139
  projectSettings: parseProjectSettings(parsed.body.projectSettings, (m) => new ValidationError(m)),
131
140
  sharedMcp: parseSharedMcp(parsed.body.sharedMcp, (m) => new ValidationError(m)),
141
+ landStrategy: optionalEnum(parsed.body.landStrategy, "landStrategy", SPAWN_LAND_STRATEGIES) as WorkspaceLandingPolicy | undefined,
142
+ autoMerge: optionalEnum(parsed.body.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
143
+ reviewer: cleanString(parsed.body.reviewer, "reviewer", { max: 240 }),
132
144
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
133
145
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
134
146
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
135
147
  spawnRequestId: cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }),
136
148
  requestedVia: "dashboard",
137
149
  requestedBy: "dashboard",
150
+ resumeWorkspace: parseResumeWorkspaceHttp(parsed.body.resumeWorkspace),
138
151
  // HTTP is fire-and-forget (202): the dashboard polls registration separately via the
139
152
  // returned spawnRequestId.
140
153
  waitForRegistrationMs: 0,
@@ -7,6 +7,8 @@ import { cleanStringArray } from "../validation";
7
7
  import { clearActiveMemories, memoryBroker } from "../memory-service";
8
8
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
9
9
  import { notifyAgentReady } from "../agent-lifecycle-events";
10
+ import { flushQueuedDirectMessages } from "../services/managed-running";
11
+ import { isAgentUnavailable } from "../db/agent-predicates";
10
12
  import { getCompactionWatch } from "../compaction-watch";
11
13
  import { isRecord } from "agent-relay-sdk";
12
14
  import { type AgentCard } from "../types";
@@ -210,7 +212,15 @@ export const postHeartbeat: Handler = async (req, params) => {
210
212
  const guard = normalizeAgentSessionGuard(req, parsed.body);
211
213
  const session = validateAgentSession(params.id!, guard);
212
214
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
213
- return heartbeat(params.id!, guard) ? json({ ok: true }) : error("agent not found", 404);
215
+ // #416 capture unavailability before heartbeat to detect the transition.
216
+ const beforeHeartbeat = getAgent(params.id!);
217
+ const wasUnavailable = beforeHeartbeat ? isAgentUnavailable(beforeHeartbeat) : false;
218
+ if (!heartbeat(params.id!, guard)) return error("agent not found", 404);
219
+ if (wasUnavailable) {
220
+ const after = getAgent(params.id!);
221
+ if (after && !isAgentUnavailable(after)) flushQueuedDirectMessages(params.id!);
222
+ }
223
+ return json({ ok: true });
214
224
  } catch (e) {
215
225
  if (e instanceof ValidationError) return error(e.message, 400);
216
226
  throw e;
@@ -287,7 +297,12 @@ export const patchAgentReady: Handler = async (req, params) => {
287
297
  // #308 — a spawned child flipping false→true ready is "genuinely ready" (past onboarding):
288
298
  // wake its parent so it can stop block-polling. notifyAgentReady no-ops for non-spawned
289
299
  // agents and dedups repeat readies.
290
- if (body.ready && !before?.ready) notifyAgentReady(params.id!);
300
+ if (body.ready && !before?.ready) {
301
+ notifyAgentReady(params.id!);
302
+ // #416 — also flush queued direct messages: a not-ready agent that becomes ready
303
+ // may have queued messages waiting (e.g. starting up, clearing context).
304
+ flushQueuedDirectMessages(params.id!);
305
+ }
291
306
  } catch (e) {
292
307
  if (e instanceof ValidationError) return error(e.message, 400);
293
308
  throw e;
@@ -13,6 +13,7 @@ import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
14
  import { ServiceAuthError } from "../services/errors";
15
15
  import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
+ import { applyIdleRefreshResult } from "../maintenance/workspaces";
16
17
  import { seedProviderConfigs } from "../provider-config-store";
17
18
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
18
19
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
@@ -186,6 +187,10 @@ export const patchCommand: Handler = async (req, params) => {
186
187
  });
187
188
  }
188
189
  }
190
+ if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
191
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
192
+ if (workspaceId) applyIdleRefreshResult(workspaceId, command.result);
193
+ }
189
194
  if (command.type === "workspace.merge") {
190
195
  // Merge settled (either way) — free the per-repo merge lease so the next
191
196
  // base merge can proceed (issue #157).
@@ -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
+ }
@@ -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);