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.
- 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-BwEOOqdj.js → PlanGraphPanel-BuWerKhs.js} +2 -2
- package/public/assets/{PlanGraphPanel-BwEOOqdj.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
- package/public/assets/activity-tuTEn3vn.js +2 -0
- package/public/assets/{activity-DD9bl5uf.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-DJgqCIqi.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-Vbei8P9W.js → automation-BAQM1dBh.js} +2 -2
- package/public/assets/{automation-Vbei8P9W.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-Dw5xpc5W.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/display-CWMHll1J.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-BisZ0FtO.js → formatted-body-impl-DuqZunxL.js} +2 -2
- package/public/assets/{formatted-body-impl-BisZ0FtO.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-D9ytYZyE.js → insights-C0lZ7n8D.js} +2 -2
- package/public/assets/{insights-D9ytYZyE.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-BA93FvLR.js → maintenance-Dh9nzjYV.js} +2 -2
- package/public/assets/{maintenance-BA93FvLR.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
- package/public/assets/{managed-agents-DGwdEIzA.js → managed-agents-Bsveljn4.js} +4 -4
- package/public/assets/{managed-agents-DGwdEIzA.js.map → managed-agents-Bsveljn4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js → markdown-preview-impl-CYpMFis9.js} +2 -2
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
- package/public/assets/{memory-CHzkRJqf.js → memory-DMFQMXrp.js} +2 -2
- package/public/assets/{memory-CHzkRJqf.js.map → memory-DMFQMXrp.js.map} +1 -1
- package/public/assets/messages-Cnzc7j50.js +2 -0
- package/public/assets/{messages-B7CJ5tgT.js.map → messages-Cnzc7j50.js.map} +1 -1
- package/public/assets/{orchestrators-DOy9xIx2.js → orchestrators-DSoDPNVS.js} +2 -2
- package/public/assets/{orchestrators-DOy9xIx2.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
- package/public/assets/{overview-DLbv1BWa.js → overview-OsRxo5XQ.js} +2 -2
- package/public/assets/{overview-DLbv1BWa.js.map → overview-OsRxo5XQ.js.map} +1 -1
- package/public/assets/{pairs-BDQo-k3F.js → pairs-D4UiUhyW.js} +2 -2
- package/public/assets/{pairs-BDQo-k3F.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-9WrCcrWs.js → security-CC2F6xdd.js} +2 -2
- package/public/assets/{security-9WrCcrWs.js.map → security-CC2F6xdd.js.map} +1 -1
- package/public/assets/{select-CRjeGZ9a.js → select-CD38f1WQ.js} +2 -2
- package/public/assets/{select-CRjeGZ9a.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-86St2vpn.js → tasks-BkrhWMw7.js} +2 -2
- package/public/assets/{tasks-86St2vpn.js.map → tasks-BkrhWMw7.js.map} +1 -1
- package/public/assets/{teams-C9WUxzud.js → teams-DyzaREAg.js} +2 -2
- package/public/assets/{teams-C9WUxzud.js.map → teams-DyzaREAg.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.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-tomKla2H.js.map → user-avatar-C7i11_ju.js.map} +1 -1
- package/public/assets/{work-queue-BtLMzf0g.js → work-queue-DHp4p1Zk.js} +2 -2
- package/public/assets/{work-queue-BtLMzf0g.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/bus.ts +9 -2
- package/src/config-store.ts +13 -26
- package/src/config.ts +1 -0
- package/src/context-advisory.ts +20 -10
- package/src/db/agent-predicates.ts +21 -0
- package/src/db/agents.ts +3 -3
- package/src/db/delivery.ts +38 -0
- package/src/db/messages.ts +18 -1
- package/src/db/migrations.ts +17 -0
- package/src/db/scheduled-timers.ts +33 -12
- package/src/db/schema.ts +4 -0
- package/src/db/workspaces.ts +44 -2
- package/src/maintenance/constants.ts +2 -0
- package/src/maintenance/jobs.ts +21 -8
- package/src/maintenance/workspaces.ts +76 -0
- package/src/mcp/index.ts +1 -1
- package/src/mcp/tools-messaging.ts +13 -3
- package/src/mcp/tools-spawn.ts +30 -2
- 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/agents-spawn.ts +15 -2
- package/src/routes/agents.ts +17 -2
- package/src/routes/commands.ts +5 -0
- 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/managed-running.ts +18 -1
- package/src/services/rate-limit-resume.ts +3 -0
- package/src/services/register-agent.ts +7 -1
- package/src/services/scheduler.ts +29 -1
- package/src/services/spawn-agent.ts +62 -2
- package/src/settings/registry.ts +30 -44
- package/src/spawn-command.ts +10 -0
- package/src/sse.ts +1 -0
- package/src/validation.ts +7 -2
- package/src/workspace-auto-merge.ts +53 -9
- package/public/assets/MessageBubble-BORB9AKu.js +0 -3
- package/public/assets/MessageBubble-BORB9AKu.js.map +0 -1
- package/public/assets/activity-DD9bl5uf.js +0 -2
- package/public/assets/agents-DJgqCIqi.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-Dw5xpc5W.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-Bsi2NH0v.js +0 -23
- package/public/assets/index-Bsi2NH0v.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-B7CJ5tgT.js +0 -2
- package/public/assets/settings-Durch6xW.js +0 -4
- package/public/assets/settings-Durch6xW.js.map +0 -1
- package/public/assets/user-avatar-tomKla2H.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: {},
|
|
@@ -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,
|
package/src/routes/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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;
|
package/src/routes/commands.ts
CHANGED
|
@@ -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).
|
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);
|