agent-relay-server 0.59.0 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +156 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-vzupFQD8.js +2 -0
- package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
- package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
- package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
- package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
- package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
- package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
- package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
- package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
- package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
- package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
- package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
- package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
- package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
- package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
- package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
- package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
- package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
- package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
- package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
- package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
- package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
- package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
- package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
- package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
- package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
- package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
- package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
- package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
- package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
- package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
- package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
- package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
- package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
- package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
- package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
- package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
- package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
- package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
- package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
- package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
- package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
- package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
- package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
- package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
- package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
- package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
- package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
- package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
- package/public/assets/store-DXdfhiVf.js.map +1 -0
- package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
- package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
- package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
- package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
- package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
- package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
- package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
- package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-lifecycle-events.ts +41 -21
- package/src/branch-landed.ts +46 -35
- package/src/channel-target.ts +10 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/merges.ts +89 -0
- package/src/config.ts +9 -0
- package/src/context-advisory.ts +13 -6
- package/src/db/merge-lease.ts +95 -1
- package/src/db/message-reads.ts +6 -0
- package/src/db/messages.ts +16 -1
- package/src/db/migrations.ts +30 -0
- package/src/db/notification-subscriptions.ts +198 -0
- package/src/db/schema.ts +11 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +40 -21
- package/src/mcp-merge-tools.ts +66 -0
- package/src/mcp-notification-subscription-tools.ts +111 -0
- package/src/mcp.ts +5 -3
- package/src/merge-pause-maintenance.ts +13 -0
- package/src/notification-router.ts +168 -0
- package/src/notification-rules.ts +186 -0
- package/src/notify.ts +9 -0
- package/src/reviewer-pipeline.ts +54 -40
- package/src/routes/agent-sessions.ts +4 -2
- package/src/routes/commands.ts +20 -0
- package/src/routes/index.ts +7 -0
- package/src/routes/merges.ts +95 -0
- package/src/security.ts +2 -0
- package/src/self-resume.ts +11 -6
- package/src/services/merge-pause.ts +37 -0
- package/src/services/plan-live-bindings.ts +11 -6
- package/src/services/rate-limit-resume.ts +17 -10
- package/src/services/spawn-agent.ts +10 -8
- package/src/services/transient-agent-reaper.ts +11 -6
- package/src/token-db.ts +3 -3
- package/src/workspace-pr-completion.ts +85 -8
- package/public/assets/MessageBubble-BrEKymoV.js +0 -2
- package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
- package/public/assets/store-DHO_a8BD.js.map +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { DEFAULT_TTL_MS, type NotificationScope, type NotificationType } from "../notification-router";
|
|
4
|
+
import { setSubscriptionRules, type NotificationRule, type NotificationRuleContext, type RuleDecision } from "../notification-rules";
|
|
5
|
+
import type { AgentCard } from "../types";
|
|
6
|
+
import { parseJson } from "../utils";
|
|
7
|
+
import { getDb, ValidationError } from "./connection";
|
|
8
|
+
|
|
9
|
+
export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
|
|
10
|
+
export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
|
|
11
|
+
|
|
12
|
+
export type NotificationSubscriptionType = NotificationType | "*";
|
|
13
|
+
export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
|
|
14
|
+
|
|
15
|
+
export interface NotificationSubscription {
|
|
16
|
+
id: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
type: NotificationSubscriptionType;
|
|
19
|
+
mode: NotificationSubscriptionMode;
|
|
20
|
+
scopeMatch: Partial<NotificationScope> | null;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NotificationSubscriptionRow {
|
|
25
|
+
id: string;
|
|
26
|
+
agent_id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
mode: string;
|
|
29
|
+
scope_match: string | null;
|
|
30
|
+
created_at: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCOPE_KEYS = ["author", "agentId", "parent", "teamId", "planId", "repoRoot", "workspaceId", "paths"] as const;
|
|
34
|
+
|
|
35
|
+
export function addSubscription(input: {
|
|
36
|
+
agentId: string;
|
|
37
|
+
type: NotificationSubscriptionType;
|
|
38
|
+
mode: NotificationSubscriptionMode;
|
|
39
|
+
scopeMatch?: Partial<NotificationScope> | null;
|
|
40
|
+
now?: number;
|
|
41
|
+
}): NotificationSubscription {
|
|
42
|
+
const agentId = cleanAgentId(input.agentId);
|
|
43
|
+
const type = cleanSubscriptionType(input.type);
|
|
44
|
+
const mode = cleanSubscriptionMode(input.mode);
|
|
45
|
+
const scopeMatch = normalizeScopeMatch(input.scopeMatch);
|
|
46
|
+
const now = input.now ?? Date.now();
|
|
47
|
+
|
|
48
|
+
getDb().query(`
|
|
49
|
+
DELETE FROM notification_subscriptions
|
|
50
|
+
WHERE agent_id = ? AND type = ? AND mode = ? AND coalesce(scope_match, '') = coalesce(?, '')
|
|
51
|
+
`).run(agentId, type, mode, scopeMatch);
|
|
52
|
+
|
|
53
|
+
const id = `nsub_${randomUUID()}`;
|
|
54
|
+
getDb().query(`
|
|
55
|
+
INSERT INTO notification_subscriptions (id, agent_id, type, mode, scope_match, created_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
57
|
+
`).run(id, agentId, type, mode, scopeMatch, now);
|
|
58
|
+
|
|
59
|
+
return getSubscription(id)!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function removeSubscription(input: {
|
|
63
|
+
agentId: string;
|
|
64
|
+
id?: string;
|
|
65
|
+
type?: NotificationSubscriptionType;
|
|
66
|
+
mode?: NotificationSubscriptionMode;
|
|
67
|
+
scopeMatch?: Partial<NotificationScope> | null;
|
|
68
|
+
}): number {
|
|
69
|
+
const agentId = cleanAgentId(input.agentId);
|
|
70
|
+
const conditions = ["agent_id = ?"];
|
|
71
|
+
const params: any[] = [agentId];
|
|
72
|
+
|
|
73
|
+
if (input.id) {
|
|
74
|
+
conditions.push("id = ?");
|
|
75
|
+
params.push(input.id);
|
|
76
|
+
}
|
|
77
|
+
if (input.type) {
|
|
78
|
+
conditions.push("type = ?");
|
|
79
|
+
params.push(cleanSubscriptionType(input.type));
|
|
80
|
+
}
|
|
81
|
+
if (input.mode) {
|
|
82
|
+
conditions.push("mode = ?");
|
|
83
|
+
params.push(cleanSubscriptionMode(input.mode));
|
|
84
|
+
}
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(input, "scopeMatch")) {
|
|
86
|
+
conditions.push("coalesce(scope_match, '') = coalesce(?, '')");
|
|
87
|
+
params.push(normalizeScopeMatch(input.scopeMatch));
|
|
88
|
+
}
|
|
89
|
+
if (conditions.length === 1) throw new ValidationError("id or type required");
|
|
90
|
+
|
|
91
|
+
return getDb().query(`DELETE FROM notification_subscriptions WHERE ${conditions.join(" AND ")}`).run(...params).changes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listSubscriptions(agentId: string): NotificationSubscription[] {
|
|
95
|
+
const rows = getDb().query(`
|
|
96
|
+
SELECT * FROM notification_subscriptions
|
|
97
|
+
WHERE agent_id = ?
|
|
98
|
+
ORDER BY created_at ASC, id ASC
|
|
99
|
+
`).all(cleanAgentId(agentId)) as NotificationSubscriptionRow[];
|
|
100
|
+
return rows.map(rowToSubscription);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function subscriptionDecisionFor(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision {
|
|
104
|
+
const rows = getDb().query(`
|
|
105
|
+
SELECT * FROM notification_subscriptions
|
|
106
|
+
WHERE agent_id = ? AND (type = ? OR type = '*')
|
|
107
|
+
ORDER BY created_at ASC, id ASC
|
|
108
|
+
`).all(agent.id, ctx.type) as NotificationSubscriptionRow[];
|
|
109
|
+
|
|
110
|
+
let decision: RuleDecision = "abstain";
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const subscription = rowToSubscription(row);
|
|
113
|
+
if (!scopeMatches(subscription.scopeMatch, ctx.scope)) continue;
|
|
114
|
+
decision = subscription.mode === "opt_in" ? "deliver" : "suppress";
|
|
115
|
+
}
|
|
116
|
+
return decision;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const subscriptionRule: NotificationRule = {
|
|
120
|
+
name: "subscription:store",
|
|
121
|
+
evaluate: subscriptionDecisionFor,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function installNotificationSubscriptionRule(): void {
|
|
125
|
+
setSubscriptionRules([subscriptionRule]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSubscription(id: string): NotificationSubscription | null {
|
|
129
|
+
const row = getDb().query("SELECT * FROM notification_subscriptions WHERE id = ?").get(id) as NotificationSubscriptionRow | undefined;
|
|
130
|
+
return row ? rowToSubscription(row) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function cleanSubscriptionType(value: unknown): NotificationSubscriptionType {
|
|
134
|
+
if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_TYPES.includes(value as NotificationSubscriptionType)) {
|
|
135
|
+
throw new ValidationError(`type must be one of: ${NOTIFICATION_SUBSCRIPTION_TYPES.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
return value as NotificationSubscriptionType;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function cleanSubscriptionMode(value: unknown): NotificationSubscriptionMode {
|
|
141
|
+
if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_MODES.includes(value as NotificationSubscriptionMode)) {
|
|
142
|
+
throw new ValidationError(`mode must be one of: ${NOTIFICATION_SUBSCRIPTION_MODES.join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
return value as NotificationSubscriptionMode;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cleanAgentId(value: unknown): string {
|
|
148
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError("agentId required");
|
|
149
|
+
return value.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeScopeMatch(scope: Partial<NotificationScope> | null | undefined): string | null {
|
|
153
|
+
if (scope === undefined || scope === null) return null;
|
|
154
|
+
if (!isRecord(scope)) throw new ValidationError("scope must be an object");
|
|
155
|
+
const normalized: Record<string, unknown> = {};
|
|
156
|
+
for (const key of SCOPE_KEYS) {
|
|
157
|
+
const value = scope[key];
|
|
158
|
+
if (value === undefined || value === null) continue;
|
|
159
|
+
if (key === "paths") {
|
|
160
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
161
|
+
throw new ValidationError("scope.paths must be an array of strings");
|
|
162
|
+
}
|
|
163
|
+
const paths = [...new Set(value.map((item) => item.trim()).filter(Boolean))].sort();
|
|
164
|
+
if (paths.length) normalized.paths = paths;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`scope.${key} must be a string`);
|
|
168
|
+
normalized[key] = value.trim();
|
|
169
|
+
}
|
|
170
|
+
return Object.keys(normalized).length ? JSON.stringify(normalized) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rowToSubscription(row: NotificationSubscriptionRow): NotificationSubscription {
|
|
174
|
+
return {
|
|
175
|
+
id: row.id,
|
|
176
|
+
agentId: row.agent_id,
|
|
177
|
+
type: cleanSubscriptionType(row.type),
|
|
178
|
+
mode: cleanSubscriptionMode(row.mode),
|
|
179
|
+
scopeMatch: parseJson<Partial<NotificationScope> | null>(row.scope_match, null),
|
|
180
|
+
createdAt: row.created_at,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scopeMatches(match: Partial<NotificationScope> | null, scope: NotificationScope): boolean {
|
|
185
|
+
if (!match) return true;
|
|
186
|
+
const actual = scope as Record<string, unknown>;
|
|
187
|
+
for (const [key, expected] of Object.entries(match)) {
|
|
188
|
+
const value = actual[key];
|
|
189
|
+
if (Array.isArray(expected)) {
|
|
190
|
+
if (!Array.isArray(value)) return false;
|
|
191
|
+
const actualSet = new Set(value.filter((item): item is string => typeof item === "string"));
|
|
192
|
+
if (!expected.every((item) => actualSet.has(item))) return false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (value !== expected) return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -110,6 +110,17 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
110
110
|
last_seen INTEGER NOT NULL,
|
|
111
111
|
created_at INTEGER NOT NULL
|
|
112
112
|
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
116
|
+
type TEXT NOT NULL,
|
|
117
|
+
mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
|
|
118
|
+
scope_match TEXT,
|
|
119
|
+
created_at INTEGER NOT NULL
|
|
120
|
+
);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type);
|
|
122
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
|
|
123
|
+
ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''));
|
|
113
124
|
CREATE TABLE IF NOT EXISTS messages (
|
|
114
125
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
115
126
|
from_agent TEXT NOT NULL,
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getLifecycleManager } from "./lifecycle-manager";
|
|
|
10
10
|
import { getCompactionWatch } from "./compaction-watch";
|
|
11
11
|
import { getConfig, setConfig } from "./config-store";
|
|
12
12
|
import { startConnectorStatusPoller } from "./connectors";
|
|
13
|
+
import { installNotificationSubscriptionRule } from "./db/notification-subscriptions";
|
|
13
14
|
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
14
15
|
import { resolve, sep } from "path";
|
|
15
16
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
@@ -74,6 +75,7 @@ function startServer(): void {
|
|
|
74
75
|
|
|
75
76
|
assertSafeNetworkConfig(HOST);
|
|
76
77
|
initDb(DB_PATH);
|
|
78
|
+
installNotificationSubscriptionRule();
|
|
77
79
|
startPlanLiveBindings();
|
|
78
80
|
getLifecycleManager().start();
|
|
79
81
|
getCompactionWatch().start();
|
package/src/maintenance.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
expireQueuedMessages,
|
|
14
14
|
getAgent,
|
|
15
15
|
getDb,
|
|
16
|
+
getActiveMergePause,
|
|
16
17
|
getRepoSteward,
|
|
17
18
|
getWorkspace,
|
|
18
19
|
listActiveTeams,
|
|
@@ -59,11 +60,12 @@ import {
|
|
|
59
60
|
emitPoolBindingChanged,
|
|
60
61
|
emitTaskChanged,
|
|
61
62
|
} from "./sse";
|
|
62
|
-
import {
|
|
63
|
+
import { routeNotification, type NotificationType, type NotificationScope } from "./notification-router";
|
|
63
64
|
import { pruneExpiredTokenRecords } from "./token-db";
|
|
64
65
|
import { dissolveTeamById } from "./services/team";
|
|
65
66
|
import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
|
|
66
67
|
import { resumeRateLimitedAgents, resetRateLimitResumeTrackerForTests } from "./services/rate-limit-resume";
|
|
68
|
+
import { MERGE_PAUSE_MAINTENANCE_JOB } from "./merge-pause-maintenance";
|
|
67
69
|
import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
|
|
68
70
|
|
|
69
71
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -197,6 +199,7 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
197
199
|
};
|
|
198
200
|
},
|
|
199
201
|
},
|
|
202
|
+
MERGE_PAUSE_MAINTENANCE_JOB,
|
|
200
203
|
{
|
|
201
204
|
id: "task-orphan-release",
|
|
202
205
|
title: "Orphaned task release",
|
|
@@ -633,10 +636,16 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
|
|
|
633
636
|
const policyName = ensureRepoSteward(ws.repoRoot);
|
|
634
637
|
if (!policyName) return null;
|
|
635
638
|
try {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
639
|
+
// Obligation (ttl null by type default): a steward task must survive until claimed/handled.
|
|
640
|
+
routeNotification({
|
|
641
|
+
type: "workspace.steward_task",
|
|
642
|
+
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
643
|
+
recipients: [`policy:${policyName}`],
|
|
644
|
+
message: {
|
|
645
|
+
subject: `Steward: ${ws.status} workspace needs attention`,
|
|
646
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
|
|
647
|
+
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
|
|
648
|
+
},
|
|
640
649
|
});
|
|
641
650
|
getLifecycleManager().onMessageForPolicy(policyName);
|
|
642
651
|
patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
|
|
@@ -653,9 +662,8 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
653
662
|
const candidates = listWorkspaces().filter(
|
|
654
663
|
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
|
|
655
664
|
);
|
|
656
|
-
const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
|
|
665
|
+
const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [], prRecycled: string[] = [];
|
|
657
666
|
const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
|
|
658
|
-
const prRecycled: string[] = [];
|
|
659
667
|
|
|
660
668
|
for (const ws of candidates) {
|
|
661
669
|
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
@@ -702,6 +710,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
702
710
|
const prAction = preparePrCompletionScan(ws, p, orch.agentId);
|
|
703
711
|
if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
|
|
704
712
|
if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
|
|
713
|
+
if (prAction.refreshCommand) { emitCommand(prAction.refreshCommand); continue; }
|
|
705
714
|
if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
|
|
706
715
|
if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
|
|
707
716
|
if (prAction.stop) continue;
|
|
@@ -740,10 +749,16 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
740
749
|
if (woke) notifiedStewards.push(woke);
|
|
741
750
|
} else if (ws.stewardAgentId) {
|
|
742
751
|
try {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
752
|
+
// Obligation (ttl null by type default): a conflict handoff must reach the steward.
|
|
753
|
+
routeNotification({
|
|
754
|
+
type: "workspace.conflict",
|
|
755
|
+
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
756
|
+
recipients: [ws.stewardAgentId],
|
|
757
|
+
message: {
|
|
758
|
+
subject: "Workspace merge conflict",
|
|
759
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
|
|
760
|
+
payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
|
|
761
|
+
},
|
|
747
762
|
});
|
|
748
763
|
notifiedStewards.push(ws.stewardAgentId);
|
|
749
764
|
} catch {
|
|
@@ -774,16 +789,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
774
789
|
);
|
|
775
790
|
const stewardEnabled = getStewardConfig().enabled;
|
|
776
791
|
const merged: string[] = [];
|
|
777
|
-
const heldByLease: string[] = [];
|
|
778
|
-
const heldByClaim: string[] = [];
|
|
779
|
-
const heldAwaitingApproval: string[] = [];
|
|
780
|
-
const leftForSteward: string[] = [];
|
|
781
|
-
const wokeStewards: string[] = [];
|
|
792
|
+
const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
|
|
782
793
|
|
|
783
794
|
for (const ws of candidates) {
|
|
784
795
|
if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
|
|
785
796
|
// A claimed workspace is being validated by a steward — don't race it (#208).
|
|
786
797
|
if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
|
|
798
|
+
if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
|
|
787
799
|
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
788
800
|
if (!orch?.apiUrl) continue;
|
|
789
801
|
const preview = await fetchHostMergePreview(orch.apiUrl, ws);
|
|
@@ -845,15 +857,16 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
845
857
|
});
|
|
846
858
|
}
|
|
847
859
|
|
|
848
|
-
return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
|
|
860
|
+
return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
|
|
849
861
|
}
|
|
850
862
|
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
863
|
+
// Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
|
|
864
|
+
// break the GC sweep). Returns the target on success, null otherwise. These are all obligation-style
|
|
865
|
+
// workspace handoffs (null TTL by type default) — they must survive until the recipient acts.
|
|
866
|
+
function notifyTarget(type: NotificationType, scope: NotificationScope, target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
|
|
854
867
|
if (!target) return null;
|
|
855
868
|
try {
|
|
856
|
-
|
|
869
|
+
routeNotification({ type, scope, recipients: [target], message: { subject, body, payload } });
|
|
857
870
|
return target;
|
|
858
871
|
} catch {
|
|
859
872
|
return null;
|
|
@@ -905,6 +918,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
905
918
|
// steward hasn't been told, hand it off explicitly, then clear markers.
|
|
906
919
|
if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
|
|
907
920
|
const sent = notifyTarget(
|
|
921
|
+
"workspace.steward_reassigned",
|
|
922
|
+
{ workspaceId: fresh.id, repoRoot: fresh.repoRoot },
|
|
908
923
|
steward!,
|
|
909
924
|
"Workspace stewardship reassigned",
|
|
910
925
|
`You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward — please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
|
|
@@ -920,6 +935,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
920
935
|
if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
|
|
921
936
|
if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
|
|
922
937
|
const sent = notifyTarget(
|
|
938
|
+
"workspace.stranded_escalation",
|
|
939
|
+
{ workspaceId: fresh.id, repoRoot: fresh.repoRoot },
|
|
923
940
|
fallbackTarget,
|
|
924
941
|
"Stranded workspace needs an owner",
|
|
925
942
|
`Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
|
|
@@ -951,6 +968,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
|
951
968
|
// stranded abandon isn't silent (issue #157).
|
|
952
969
|
const target = ws.stewardAgentId ?? fallbackTarget;
|
|
953
970
|
const sent = notifyTarget(
|
|
971
|
+
"workspace.auto_abandoned",
|
|
972
|
+
{ workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
954
973
|
target,
|
|
955
974
|
"Workspace auto-abandoned",
|
|
956
975
|
`Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ValidationError } from "./db";
|
|
2
|
+
import { McpNotFoundError } from "./mcp-errors";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { listAutoMergePauses, pauseAutoMerge, renewAutoMergePause, resumeAutoMerge } from "./services/merge-pause";
|
|
5
|
+
import { cleanString } from "./validation";
|
|
6
|
+
|
|
7
|
+
export const MERGE_TOOLS = [
|
|
8
|
+
{ name: "relay_merge_pauses", description: "List active auto-merge pauses. These are repo/base-level holds that make the workspace auto-merge job yield during release or build critical sections.", requiredScopes: ["command:read"], inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
9
|
+
{
|
|
10
|
+
name: "relay_merge_pause",
|
|
11
|
+
description: "Pause workspace auto-merge for one repo/base pair with a TTL. Existing active pause for the same target is returned as a conflict instead of replacing its holder.",
|
|
12
|
+
requiredScopes: ["command:write"],
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
repoRoot: { type: "string", description: "Repository root whose auto-merge should pause." },
|
|
17
|
+
baseRef: { type: "string", description: "Base branch/ref to pause (default main)." },
|
|
18
|
+
ttlMs: { type: "integer", minimum: 1, description: "Pause TTL in milliseconds. Defaults to the server's merge-pause TTL." },
|
|
19
|
+
reason: { type: "string" },
|
|
20
|
+
holder: { type: "string", description: "Optional human-readable holder. Defaults to the authenticated caller." },
|
|
21
|
+
},
|
|
22
|
+
required: ["repoRoot"],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{ name: "relay_merge_renew", description: "Renew an active auto-merge pause by id, extending its TTL from now.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, ttlMs: { type: "integer", minimum: 1 } }, required: ["id"], additionalProperties: false } },
|
|
27
|
+
{ name: "relay_merge_resume", description: "Release an auto-merge pause by id, or by repoRoot/baseRef.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, repoRoot: { type: "string" }, baseRef: { type: "string", description: "Base branch/ref (default main when repoRoot is used)." } }, additionalProperties: false } },
|
|
28
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
29
|
+
|
|
30
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
31
|
+
|
|
32
|
+
export function relayMergeTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
const ctx = authContextFromMcp(auth);
|
|
34
|
+
if (name === "relay_merge_pauses") {
|
|
35
|
+
const pauses = listAutoMergePauses();
|
|
36
|
+
return { pauses, count: pauses.length };
|
|
37
|
+
}
|
|
38
|
+
if (name === "relay_merge_pause") {
|
|
39
|
+
const result = pauseAutoMerge({
|
|
40
|
+
repoRoot: cleanString(args.repoRoot, "repoRoot", { required: true, max: 1024 })!,
|
|
41
|
+
baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }),
|
|
42
|
+
holder: cleanString(args.holder, "holder", { max: 240 }) ?? ctx.callerAgentId ?? ctx.actor.id,
|
|
43
|
+
reason: cleanString(args.reason, "reason", { max: 1000 }),
|
|
44
|
+
ttlMs: optionalPositiveInt(args.ttlMs, "ttlMs"),
|
|
45
|
+
});
|
|
46
|
+
return result.ok ? { ok: true, pause: result.pause } : { ok: false, error: "merge target already paused", pause: result.pause };
|
|
47
|
+
}
|
|
48
|
+
if (name === "relay_merge_renew") {
|
|
49
|
+
const pause = renewAutoMergePause(cleanString(args.id, "id", { required: true, max: 120 })!, optionalPositiveInt(args.ttlMs, "ttlMs"));
|
|
50
|
+
if (!pause) throw new McpNotFoundError("merge pause not found or expired");
|
|
51
|
+
return { pause };
|
|
52
|
+
}
|
|
53
|
+
if (name === "relay_merge_resume") {
|
|
54
|
+
const id = cleanString(args.id, "id", { max: 120 });
|
|
55
|
+
const repoRoot = cleanString(args.repoRoot, "repoRoot", { max: 1024 });
|
|
56
|
+
if (!id && !repoRoot) throw new ValidationError("id or repoRoot required");
|
|
57
|
+
return { released: resumeAutoMerge({ id, repoRoot, baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }) }) };
|
|
58
|
+
}
|
|
59
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function optionalPositiveInt(value: unknown, field: string): number | undefined {
|
|
63
|
+
if (value === undefined || value === null) return undefined;
|
|
64
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addSubscription,
|
|
3
|
+
cleanSubscriptionMode,
|
|
4
|
+
cleanSubscriptionType,
|
|
5
|
+
listSubscriptions,
|
|
6
|
+
NOTIFICATION_SUBSCRIPTION_MODES,
|
|
7
|
+
NOTIFICATION_SUBSCRIPTION_TYPES,
|
|
8
|
+
removeSubscription,
|
|
9
|
+
type NotificationSubscriptionMode,
|
|
10
|
+
} from "./db/notification-subscriptions";
|
|
11
|
+
import { ValidationError } from "./db";
|
|
12
|
+
import { McpAuthError } from "./mcp-errors";
|
|
13
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
14
|
+
import { cleanString } from "./validation";
|
|
15
|
+
import type { NotificationScope } from "./notification-router";
|
|
16
|
+
|
|
17
|
+
export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: "relay_subscribe",
|
|
20
|
+
description: "Subscribe, opt out, or mute this caller agent for a notification type. The caller's token identity is the subscription owner.",
|
|
21
|
+
requiredScopes: ["notifications:write"],
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
|
|
26
|
+
mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
|
|
27
|
+
scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
|
|
28
|
+
},
|
|
29
|
+
required: ["type"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "relay_unsubscribe",
|
|
35
|
+
description: "Remove this caller agent's notification subscription by id, or by type/mode/scope. Omitting mode removes all modes for the matched type/scope.",
|
|
36
|
+
requiredScopes: ["notifications:write"],
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
id: { type: "string" },
|
|
41
|
+
type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
|
|
42
|
+
mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
|
|
43
|
+
scope: { type: "object" },
|
|
44
|
+
},
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "relay_list_subscriptions",
|
|
50
|
+
description: "List this caller agent's durable notification subscriptions.",
|
|
51
|
+
requiredScopes: ["notifications:read"],
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {},
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
59
|
+
|
|
60
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
61
|
+
|
|
62
|
+
export function relayNotificationSubscriptionTool(
|
|
63
|
+
auth: McpAuthLike,
|
|
64
|
+
name: string,
|
|
65
|
+
args: Record<string, unknown>,
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
const agentId = callerAgentId(auth);
|
|
68
|
+
if (name === "relay_subscribe") {
|
|
69
|
+
const subscription = addSubscription({
|
|
70
|
+
agentId,
|
|
71
|
+
type: cleanSubscriptionType(args.type),
|
|
72
|
+
mode: cleanModeOrDefault(args.mode),
|
|
73
|
+
scopeMatch: cleanScope(args.scope),
|
|
74
|
+
});
|
|
75
|
+
return { subscription };
|
|
76
|
+
}
|
|
77
|
+
if (name === "relay_unsubscribe") {
|
|
78
|
+
const id = cleanString(args.id, "id", { max: 120 });
|
|
79
|
+
const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
|
|
80
|
+
if (!id && !type) throw new ValidationError("id or type required");
|
|
81
|
+
const removed = removeSubscription({
|
|
82
|
+
agentId,
|
|
83
|
+
id,
|
|
84
|
+
type,
|
|
85
|
+
mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
|
|
86
|
+
...(Object.prototype.hasOwnProperty.call(args, "scope") ? { scopeMatch: cleanScope(args.scope) } : {}),
|
|
87
|
+
});
|
|
88
|
+
return { removed };
|
|
89
|
+
}
|
|
90
|
+
if (name === "relay_list_subscriptions") {
|
|
91
|
+
return { subscriptions: listSubscriptions(agentId) };
|
|
92
|
+
}
|
|
93
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function callerAgentId(auth: McpAuthLike): string {
|
|
97
|
+
const ctx = authContextFromMcp(auth);
|
|
98
|
+
if (!ctx.callerAgentId) throw new McpAuthError("notification subscriptions require a single caller agent identity");
|
|
99
|
+
return ctx.callerAgentId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cleanModeOrDefault(value: unknown): NotificationSubscriptionMode {
|
|
103
|
+
if (value === undefined || value === null) return "opt_in";
|
|
104
|
+
return cleanSubscriptionMode(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cleanScope(value: unknown): Partial<NotificationScope> | null {
|
|
108
|
+
if (value === undefined || value === null) return null;
|
|
109
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("scope must be an object");
|
|
110
|
+
return value as Partial<NotificationScope>;
|
|
111
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
|
|
|
50
50
|
import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
|
|
51
51
|
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
|
|
52
52
|
import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
|
|
53
|
+
import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
|
|
53
54
|
import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
|
|
54
55
|
import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
|
|
55
56
|
import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
@@ -459,10 +460,11 @@ const TOOLS: ToolDefinition[] = [
|
|
|
459
460
|
additionalProperties: false,
|
|
460
461
|
},
|
|
461
462
|
},
|
|
462
|
-
...TEAM_TOOLS,
|
|
463
|
+
...MERGE_TOOLS, ...TEAM_TOOLS,
|
|
463
464
|
...PLAN_TOOLS,
|
|
464
465
|
...BLACKBOARD_TOOLS,
|
|
465
466
|
...PROJECT_TOOLS,
|
|
467
|
+
...NOTIFICATION_SUBSCRIPTION_TOOLS,
|
|
466
468
|
];
|
|
467
469
|
|
|
468
470
|
export async function postMcp(req: Request): Promise<Response> {
|
|
@@ -575,10 +577,11 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
575
577
|
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
576
578
|
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
577
579
|
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
578
|
-
else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
|
|
580
|
+
else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
|
|
579
581
|
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
580
582
|
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
581
583
|
else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
|
|
584
|
+
else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
|
|
582
585
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
583
586
|
|
|
584
587
|
auditToolCall(auth, name, "ok", result);
|
|
@@ -1040,7 +1043,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
|
|
|
1040
1043
|
return payload;
|
|
1041
1044
|
}
|
|
1042
1045
|
|
|
1043
|
-
|
|
1044
1046
|
function payloadWithAttachments(
|
|
1045
1047
|
payload: Record<string, unknown> | undefined,
|
|
1046
1048
|
attachments: AttachmentRef[] | undefined,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { REAP_INTERVAL_MS } from "./config";
|
|
2
|
+
import { releaseExpiredMergePauses } from "./db";
|
|
3
|
+
|
|
4
|
+
export const MERGE_PAUSE_MAINTENANCE_JOB = {
|
|
5
|
+
id: "merge-pause-expiration",
|
|
6
|
+
title: "Merge pause expiration",
|
|
7
|
+
description: "Release expired repo/base auto-merge pauses.",
|
|
8
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
9
|
+
runOnStart: true,
|
|
10
|
+
handler() {
|
|
11
|
+
return { releasedMergePauseIds: releaseExpiredMergePauses() };
|
|
12
|
+
},
|
|
13
|
+
};
|