agent-relay-server 0.50.0 → 0.52.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 +206 -1
- package/package.json +2 -2
- package/public/assets/{PlanGraphPanel-BYgnvJZR.js → PlanGraphPanel-BnYmYHnY.js} +2 -2
- package/public/assets/{PlanGraphPanel-BYgnvJZR.js.map → PlanGraphPanel-BnYmYHnY.js.map} +1 -1
- package/public/assets/{activity-CCOlARUq.js → activity-BNLuP717.js} +2 -2
- package/public/assets/{activity-CCOlARUq.js.map → activity-BNLuP717.js.map} +1 -1
- package/public/assets/{agent-profiles-CGQq2EyX.js → agent-profiles-BhVEehP8.js} +2 -2
- package/public/assets/{agent-profiles-CGQq2EyX.js.map → agent-profiles-BhVEehP8.js.map} +1 -1
- package/public/assets/{agents-Cl9a1uBD.js → agents-CqQhC2aN.js} +2 -2
- package/public/assets/{agents-Cl9a1uBD.js.map → agents-CqQhC2aN.js.map} +1 -1
- package/public/assets/{analytics-DkABnOKb.js → analytics-44eqtAsx.js} +2 -2
- package/public/assets/{analytics-DkABnOKb.js.map → analytics-44eqtAsx.js.map} +1 -1
- package/public/assets/{automation-CS2V5WAh.js → automation-x65cxlk4.js} +2 -2
- package/public/assets/{automation-CS2V5WAh.js.map → automation-x65cxlk4.js.map} +1 -1
- package/public/assets/{branch-state-badge-DhTKagJn.js → branch-state-badge-Dp6Jk4LH.js} +2 -2
- package/public/assets/{branch-state-badge-DhTKagJn.js.map → branch-state-badge-Dp6Jk4LH.js.map} +1 -1
- package/public/assets/{channels-BcUZKNmr.js → channels-CmfUfiV0.js} +2 -2
- package/public/assets/{channels-BcUZKNmr.js.map → channels-CmfUfiV0.js.map} +1 -1
- package/public/assets/{chat-TFFCz8AI.js → chat-nugo0m55.js} +2 -2
- package/public/assets/{chat-TFFCz8AI.js.map → chat-nugo0m55.js.map} +1 -1
- package/public/assets/{connectors-Dd5gUsM2.js → connectors-Bf74bw_9.js} +2 -2
- package/public/assets/{connectors-Dd5gUsM2.js.map → connectors-Bf74bw_9.js.map} +1 -1
- package/public/assets/display-CXhnBwGf.js.map +1 -1
- package/public/assets/{formatted-body-impl-B_mZW3Vj.js → formatted-body-impl-UeUBqdRz.js} +2 -2
- package/public/assets/{formatted-body-impl-B_mZW3Vj.js.map → formatted-body-impl-UeUBqdRz.js.map} +1 -1
- package/public/assets/index-C0aWKi0g.css +2 -0
- package/public/assets/{index-CWs6iPpQ.js → index-D--CyG66.js} +6 -6
- package/public/assets/{index-CWs6iPpQ.js.map → index-D--CyG66.js.map} +1 -1
- package/public/assets/{insights-D5e1k0IU.js → insights-C9k7mCoJ.js} +2 -2
- package/public/assets/{insights-D5e1k0IU.js.map → insights-C9k7mCoJ.js.map} +1 -1
- package/public/assets/{integrations-DVdvwQaZ.js → integrations-By3hMfqC.js} +2 -2
- package/public/assets/{integrations-DVdvwQaZ.js.map → integrations-By3hMfqC.js.map} +1 -1
- package/public/assets/{maintenance-ChmWW3iQ.js → maintenance-tgb7AMTv.js} +2 -2
- package/public/assets/{maintenance-ChmWW3iQ.js.map → maintenance-tgb7AMTv.js.map} +1 -1
- package/public/assets/{managed-agents-C6cbTB9d.js → managed-agents-DJ3TfBJ5.js} +2 -2
- package/public/assets/{managed-agents-C6cbTB9d.js.map → managed-agents-DJ3TfBJ5.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DjmZIwx5.js → markdown-preview-impl-DcHq1Hto.js} +2 -2
- package/public/assets/{markdown-preview-impl-DjmZIwx5.js.map → markdown-preview-impl-DcHq1Hto.js.map} +1 -1
- package/public/assets/{memory-DYRuGhD9.js → memory-CMdemdtb.js} +2 -2
- package/public/assets/{memory-DYRuGhD9.js.map → memory-CMdemdtb.js.map} +1 -1
- package/public/assets/{messages-q5N9kJ4f.js → messages-CLuLC9wC.js} +2 -2
- package/public/assets/{messages-q5N9kJ4f.js.map → messages-CLuLC9wC.js.map} +1 -1
- package/public/assets/{orchestrators-bbIEU7xO.js → orchestrators-d27zYyT9.js} +2 -2
- package/public/assets/{orchestrators-bbIEU7xO.js.map → orchestrators-d27zYyT9.js.map} +1 -1
- package/public/assets/{overview-B3TxMRHV.js → overview-BY-cs5SI.js} +2 -2
- package/public/assets/{overview-B3TxMRHV.js.map → overview-BY-cs5SI.js.map} +1 -1
- package/public/assets/{pairs-CF3XEcbG.js → pairs-CmyQx_4F.js} +2 -2
- package/public/assets/{pairs-CF3XEcbG.js.map → pairs-CmyQx_4F.js.map} +1 -1
- package/public/assets/{security-Bffy1FyV.js → security-CxCnWTlk.js} +2 -2
- package/public/assets/{security-Bffy1FyV.js.map → security-CxCnWTlk.js.map} +1 -1
- package/public/assets/{select-Bz0VV6tj.js → select-B6iG4oYX.js} +2 -2
- package/public/assets/{select-Bz0VV6tj.js.map → select-B6iG4oYX.js.map} +1 -1
- package/public/assets/{settings-DUucPX58.js → settings-vSYPv8kE.js} +2 -2
- package/public/assets/{settings-DUucPX58.js.map → settings-vSYPv8kE.js.map} +1 -1
- package/public/assets/{store-C7dqPL_V.js → store-BLZA3u7Y.js} +2 -2
- package/public/assets/store-BLZA3u7Y.js.map +1 -0
- package/public/assets/{tasks-Dv1CeteT.js → tasks-BYUAWEL-.js} +2 -2
- package/public/assets/{tasks-Dv1CeteT.js.map → tasks-BYUAWEL-.js.map} +1 -1
- package/public/assets/teams-gWvyw7ET.js +2 -0
- package/public/assets/teams-gWvyw7ET.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js → terminal-viewer-impl-CHP0w79T.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js.map → terminal-viewer-impl-CHP0w79T.js.map} +1 -1
- package/public/assets/{work-queue-9iUCo-hM.js → work-queue-CBjXlNuG.js} +2 -2
- package/public/assets/{work-queue-9iUCo-hM.js.map → work-queue-CBjXlNuG.js.map} +1 -1
- package/public/assets/{workspaces-B0zGEd0M.js → workspaces-PdCwiSQx.js} +2 -2
- package/public/assets/{workspaces-B0zGEd0M.js.map → workspaces-PdCwiSQx.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/bus.ts +2 -2
- package/src/config-store.ts +10 -16
- package/src/db/agents.ts +6 -2
- package/src/db/mappers.ts +1 -0
- package/src/db/migrations.ts +5 -2
- package/src/db/plans.ts +11 -0
- package/src/db/schema.ts +1 -1
- package/src/db/teams.ts +120 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +3 -3
- package/src/mcp-agent-action.ts +45 -0
- package/src/mcp-team-template-tools.ts +133 -0
- package/src/mcp-team-tools.ts +4 -1
- package/src/mcp.ts +9 -11
- package/src/routes/index.ts +8 -1
- package/src/routes/team-templates.ts +65 -0
- package/src/routes/teams.ts +14 -2
- package/src/services/agent-action.ts +138 -0
- package/src/services/plan-graph.ts +49 -11
- package/src/services/plan-live-bindings.ts +88 -0
- package/src/services/spawn-agent.ts +5 -1
- package/src/services/team-templates.ts +172 -0
- package/src/services/transient-agent-reaper.ts +108 -0
- package/src/spawn-command.ts +2 -0
- package/src/team-template-config.ts +100 -0
- package/public/assets/index-B8ND8v33.css +0 -2
- package/public/assets/store-C7dqPL_V.js.map +0 -1
- package/public/assets/teams-DEASyStU.js +0 -2
- package/public/assets/teams-DEASyStU.js.map +0 -1
package/src/db/plans.ts
CHANGED
|
@@ -143,6 +143,17 @@ export function getPlanByTeam(teamId: string, channel?: string): Plan | null {
|
|
|
143
143
|
return row ? rowToPlan(row) : null;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
export function listPlansWithBoundOwner(agentId: string): Plan[] {
|
|
147
|
+
const rows = getDb().query(`
|
|
148
|
+
SELECT DISTINCT p.*
|
|
149
|
+
FROM plans p, json_each(p.nodes) node
|
|
150
|
+
WHERE json_extract(node.value, '$.statusSource') = 'bound'
|
|
151
|
+
AND json_extract(node.value, '$.ownerRef.agentId') = ?
|
|
152
|
+
ORDER BY p.updated_at DESC, p.id ASC
|
|
153
|
+
`).all(agentId) as PlanRow[];
|
|
154
|
+
return rows.map(rowToPlan);
|
|
155
|
+
}
|
|
156
|
+
|
|
146
157
|
export function replacePlan(input: Plan, changedBy?: string): Plan {
|
|
147
158
|
const existing = getPlan(input.id);
|
|
148
159
|
if (!existing) throw new ValidationError(`plan ${input.id} not found`);
|
package/src/db/schema.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
104
104
|
epoch INTEGER NOT NULL DEFAULT 0,
|
|
105
105
|
provider_capabilities TEXT,
|
|
106
106
|
context_state TEXT,
|
|
107
|
-
meta TEXT NOT NULL DEFAULT '{}',
|
|
107
|
+
meta TEXT NOT NULL DEFAULT '{}', lifecycle TEXT NOT NULL DEFAULT 'persistent',
|
|
108
108
|
spawned_by TEXT,
|
|
109
109
|
team_id TEXT,
|
|
110
110
|
last_seen INTEGER NOT NULL,
|
package/src/db/teams.ts
CHANGED
|
@@ -31,6 +31,46 @@ export interface TeamDissolveInput {
|
|
|
31
31
|
landsClean?: boolean;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
type OutcomeBucket = NonNullable<Team["outcomeStatus"]> | "unknown";
|
|
35
|
+
|
|
36
|
+
interface TeamOutcomeRow {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string | null;
|
|
39
|
+
outcome_status: Team["outcomeStatus"] | null;
|
|
40
|
+
outcome_summary: string | null;
|
|
41
|
+
composition: string | null;
|
|
42
|
+
wall_clock_ms: number | null;
|
|
43
|
+
dissolved_at: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TeamOutcomeCompositionAggregate {
|
|
47
|
+
shape: string;
|
|
48
|
+
members: Array<{ provider: string; model: string }>;
|
|
49
|
+
count: number;
|
|
50
|
+
successCount: number;
|
|
51
|
+
successRate: number;
|
|
52
|
+
avgWallClockMs: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TeamOutcomeRecent {
|
|
56
|
+
id: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
outcome: Team["outcomeStatus"] | null;
|
|
59
|
+
outcomeSummary?: string;
|
|
60
|
+
durationMs: number | null;
|
|
61
|
+
memberCount: number;
|
|
62
|
+
dissolvedAt: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TeamOutcomeAggregates {
|
|
66
|
+
totals: {
|
|
67
|
+
total: number;
|
|
68
|
+
byOutcomeStatus: Record<OutcomeBucket, number>;
|
|
69
|
+
};
|
|
70
|
+
compositionShapes: TeamOutcomeCompositionAggregate[];
|
|
71
|
+
recent: TeamOutcomeRecent[];
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
export function teamId(): string {
|
|
35
75
|
return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
36
76
|
}
|
|
@@ -153,6 +193,86 @@ function compositionForMembers(members: AgentCard[]): string {
|
|
|
153
193
|
})));
|
|
154
194
|
}
|
|
155
195
|
|
|
196
|
+
function compositionShape(composition: string | null): { key: string; members: Array<{ provider: string; model: string }>; memberCount: number } {
|
|
197
|
+
if (!composition) return { key: "empty", members: [], memberCount: 0 };
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(composition) as unknown;
|
|
200
|
+
if (!Array.isArray(parsed)) return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
|
|
201
|
+
const seen = new Map<string, { provider: string; model: string }>();
|
|
202
|
+
for (const raw of parsed) {
|
|
203
|
+
const member = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
|
204
|
+
const provider = typeof member.provider === "string" && member.provider.trim() ? member.provider.trim() : "unknown";
|
|
205
|
+
const model = typeof member.model === "string" && member.model.trim() ? member.model.trim() : "unknown";
|
|
206
|
+
seen.set(`${provider}/${model}`, { provider, model });
|
|
207
|
+
}
|
|
208
|
+
const members = [...seen.values()].sort((a, b) => a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model));
|
|
209
|
+
return { key: members.length ? members.map((member) => `${member.provider}/${member.model}`).join(", ") : "empty", members, memberCount: parsed.length };
|
|
210
|
+
} catch {
|
|
211
|
+
return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}): TeamOutcomeAggregates {
|
|
216
|
+
const recentLimit = Math.max(1, Math.min(50, input.recentLimit ?? 12));
|
|
217
|
+
const rows = getDb().query(`
|
|
218
|
+
SELECT id, title, outcome_status, outcome_summary, composition, wall_clock_ms, dissolved_at
|
|
219
|
+
FROM teams
|
|
220
|
+
WHERE status = 'dissolved'
|
|
221
|
+
ORDER BY dissolved_at DESC, updated_at DESC, id DESC
|
|
222
|
+
`).all() as TeamOutcomeRow[];
|
|
223
|
+
|
|
224
|
+
const byOutcomeStatus: Record<OutcomeBucket, number> = { success: 0, partial: 0, failed: 0, unknown: 0 };
|
|
225
|
+
const byShape = new Map<string, {
|
|
226
|
+
members: Array<{ provider: string; model: string }>;
|
|
227
|
+
count: number;
|
|
228
|
+
successCount: number;
|
|
229
|
+
durationTotal: number;
|
|
230
|
+
durationCount: number;
|
|
231
|
+
}>();
|
|
232
|
+
const recent: TeamOutcomeRecent[] = [];
|
|
233
|
+
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
byOutcomeStatus[row.outcome_status ?? "unknown"] += 1;
|
|
236
|
+
const shape = compositionShape(row.composition);
|
|
237
|
+
const aggregate = byShape.get(shape.key) ?? { members: shape.members, count: 0, successCount: 0, durationTotal: 0, durationCount: 0 };
|
|
238
|
+
aggregate.count += 1;
|
|
239
|
+
if (row.outcome_status === "success") aggregate.successCount += 1;
|
|
240
|
+
if (row.wall_clock_ms !== null) {
|
|
241
|
+
aggregate.durationTotal += row.wall_clock_ms;
|
|
242
|
+
aggregate.durationCount += 1;
|
|
243
|
+
}
|
|
244
|
+
byShape.set(shape.key, aggregate);
|
|
245
|
+
if (recent.length < recentLimit) {
|
|
246
|
+
recent.push({
|
|
247
|
+
id: row.id,
|
|
248
|
+
...(row.title ? { title: row.title } : {}),
|
|
249
|
+
outcome: row.outcome_status,
|
|
250
|
+
...(row.outcome_summary ? { outcomeSummary: row.outcome_summary } : {}),
|
|
251
|
+
durationMs: row.wall_clock_ms,
|
|
252
|
+
memberCount: shape.memberCount,
|
|
253
|
+
dissolvedAt: row.dissolved_at,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const compositionShapes = [...byShape.entries()]
|
|
259
|
+
.map(([shape, aggregate]) => ({
|
|
260
|
+
shape,
|
|
261
|
+
members: aggregate.members,
|
|
262
|
+
count: aggregate.count,
|
|
263
|
+
successCount: aggregate.successCount,
|
|
264
|
+
successRate: aggregate.count === 0 ? 0 : aggregate.successCount / aggregate.count,
|
|
265
|
+
avgWallClockMs: aggregate.durationCount === 0 ? null : Math.round(aggregate.durationTotal / aggregate.durationCount),
|
|
266
|
+
}))
|
|
267
|
+
.sort((a, b) => b.count - a.count || b.successRate - a.successRate || a.shape.localeCompare(b.shape));
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
totals: { total: rows.length, byOutcomeStatus },
|
|
271
|
+
compositionShapes,
|
|
272
|
+
recent,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
156
276
|
export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Team {
|
|
157
277
|
const existing = getTeam(teamId);
|
|
158
278
|
if (!existing) throw new ValidationError(`team ${teamId} not found`);
|
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 { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
13
14
|
import { resolve, sep } from "path";
|
|
14
15
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
15
16
|
import {
|
|
@@ -73,6 +74,7 @@ function startServer(): void {
|
|
|
73
74
|
|
|
74
75
|
assertSafeNetworkConfig(HOST);
|
|
75
76
|
initDb(DB_PATH);
|
|
77
|
+
startPlanLiveBindings();
|
|
76
78
|
getLifecycleManager().start();
|
|
77
79
|
getCompactionWatch().start();
|
|
78
80
|
reconcileRelayVersion();
|
package/src/maintenance.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
import { notifySystemMessage } from "./notify";
|
|
63
63
|
import { pruneExpiredTokenRecords } from "./token-db";
|
|
64
64
|
import { dissolveTeamById } from "./services/team";
|
|
65
|
+
import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
|
|
65
66
|
import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
|
|
66
67
|
|
|
67
68
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -134,9 +135,7 @@ const orphanReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_REAP !== "0";
|
|
|
134
135
|
// orchestratorId + session identity -> when we first saw it orphaned (and last reaped).
|
|
135
136
|
const orphanTracker = new Map<string, { firstOrphanedAt: number; lastReapAt?: number }>();
|
|
136
137
|
|
|
137
|
-
export function resetOrphanTrackerForTests(): void {
|
|
138
|
-
orphanTracker.clear();
|
|
139
|
-
}
|
|
138
|
+
export function resetOrphanTrackerForTests(): void { orphanTracker.clear(); resetTransientTrackerForTests(); }
|
|
140
139
|
|
|
141
140
|
interface MaintenanceJobDefinition {
|
|
142
141
|
id: string;
|
|
@@ -300,6 +299,7 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
300
299
|
runOnStart: false,
|
|
301
300
|
handler: reapOrphanedSessions,
|
|
302
301
|
},
|
|
302
|
+
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
|
|
303
303
|
{
|
|
304
304
|
id: "team-reap",
|
|
305
305
|
title: "Team lifecycle close-out",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cleanEnum, cleanString } from "./validation";
|
|
2
|
+
import { AgentActionTargetError, dispatchAgentAction, type AgentAction } from "./services/agent-action";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { ShutdownAuthError, ShutdownTargetError } from "./services/shutdown-agent";
|
|
5
|
+
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
6
|
+
|
|
7
|
+
type McpAuthLike = {
|
|
8
|
+
actor: string;
|
|
9
|
+
kind: "component" | "integration" | "server";
|
|
10
|
+
scopes?: string[];
|
|
11
|
+
component?: Parameters<typeof authContextFromMcp>[0]["component"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const AGENT_ACTION_TOOL = {
|
|
15
|
+
name: "relay_agent_action",
|
|
16
|
+
description: "Compact, clear context, or directly inject a prompt into one of your live spawned children. Gated exactly like relay_shutdown_agent: an agent caller may target only its OWN spawned children by agentId; admin tokens keep full reach.",
|
|
17
|
+
requiredScopes: ["command:shutdown"],
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
agentId: { type: "string" },
|
|
22
|
+
action: { type: "string", enum: ["compact", "clearContext", "inject"] },
|
|
23
|
+
message: { type: "string", description: "Required when action is inject." },
|
|
24
|
+
},
|
|
25
|
+
required: ["agentId", "action"],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function relayAgentAction(auth: McpAuthLike, args: Record<string, unknown>): Record<string, unknown> {
|
|
31
|
+
try {
|
|
32
|
+
const result = dispatchAgentAction({
|
|
33
|
+
agentId: cleanString(args.agentId, "agentId", { required: true, max: 240 })!,
|
|
34
|
+
action: cleanEnum(args.action, "action", ["compact", "clearContext", "inject"] as const) as AgentAction,
|
|
35
|
+
message: cleanString(args.message, "message", { max: 16_000 }),
|
|
36
|
+
requestedVia: "mcp",
|
|
37
|
+
requestedBy: auth.actor,
|
|
38
|
+
}, authContextFromMcp(auth));
|
|
39
|
+
return { ok: true, action: result.action, command: result.command };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
|
|
42
|
+
if (e instanceof ShutdownTargetError || e instanceof AgentActionTargetError) throw new McpNotFoundError(e.message);
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
2
|
+
import { McpAuthError } from "./mcp-errors";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { deleteTeamTemplate, getTeamTemplate, instantiateTeamTemplate, listTeamTemplates, saveTeamTemplate } from "./services/team-templates";
|
|
5
|
+
import { ValidationError } from "./db";
|
|
6
|
+
|
|
7
|
+
const COMPOSITION_ENTRY_SCHEMA = {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
label: { type: "string" },
|
|
11
|
+
provider: { type: "string", enum: ["claude", "codex"] },
|
|
12
|
+
model: { type: "string" },
|
|
13
|
+
effort: { type: "string" },
|
|
14
|
+
profile: { type: "string" },
|
|
15
|
+
count: { type: "integer", minimum: 1, maximum: 50 },
|
|
16
|
+
},
|
|
17
|
+
required: ["label", "provider", "count"],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const PLAN_SKELETON_SCHEMA = {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
nodes: { type: "array", items: { type: "object" } },
|
|
25
|
+
edges: { type: "array", items: { type: "object" } },
|
|
26
|
+
},
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const TEAM_TEMPLATE_TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: "relay_team_template_save",
|
|
33
|
+
description: "Create/update a reusable team template, or capture one from a dissolved team's frozen composition by passing teamId.",
|
|
34
|
+
requiredScopes: ["teams:write"],
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
id: { type: "string" },
|
|
39
|
+
slug: { type: "string" },
|
|
40
|
+
title: { type: "string" },
|
|
41
|
+
description: { type: "string" },
|
|
42
|
+
composition: { type: "array", items: COMPOSITION_ENTRY_SCHEMA },
|
|
43
|
+
brief: { type: "string" },
|
|
44
|
+
definitionOfDone: { type: "string" },
|
|
45
|
+
planSkeleton: PLAN_SKELETON_SCHEMA,
|
|
46
|
+
teamId: { type: "string", description: "Capture from this dissolved team's frozen composition snapshot instead of passing composition." },
|
|
47
|
+
},
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "relay_team_template_list",
|
|
53
|
+
description: "List saved team templates.",
|
|
54
|
+
requiredScopes: ["teams:read"],
|
|
55
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "relay_team_template_get",
|
|
59
|
+
description: "Fetch one saved team template by slug.",
|
|
60
|
+
requiredScopes: ["teams:read"],
|
|
61
|
+
inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "relay_team_template_delete",
|
|
65
|
+
description: "Delete one saved team template by slug.",
|
|
66
|
+
requiredScopes: ["teams:write"],
|
|
67
|
+
inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "relay_team_spawn_from_template",
|
|
71
|
+
description: "Instantiate a saved team template: set the caller's team brief and spawn its composition through the shared spawn service.",
|
|
72
|
+
requiredScopes: ["teams:write"],
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
slug: { type: "string" },
|
|
77
|
+
orchestratorId: { type: "string" },
|
|
78
|
+
cwd: { type: "string" },
|
|
79
|
+
approvalMode: { type: "string", enum: APPROVAL_MODES },
|
|
80
|
+
workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES },
|
|
81
|
+
lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES },
|
|
82
|
+
prompt: { type: "string" },
|
|
83
|
+
waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000 },
|
|
84
|
+
},
|
|
85
|
+
required: ["slug"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
90
|
+
|
|
91
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
92
|
+
|
|
93
|
+
function hasScope(auth: McpAuthLike, scope: string): boolean {
|
|
94
|
+
if (auth.scopes?.includes("*") || auth.scopes?.includes("admin:*")) return true;
|
|
95
|
+
const [category] = scope.split(":");
|
|
96
|
+
return Boolean(auth.scopes?.includes(scope) || (category && auth.scopes?.includes(`${category}:*`)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireScope(auth: McpAuthLike, scope: string): void {
|
|
100
|
+
if (!hasScope(auth, scope)) throw new McpAuthError(`tool requires ${scope}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function slugArg(args: Record<string, unknown>): string {
|
|
104
|
+
const slug = args.slug;
|
|
105
|
+
if (typeof slug !== "string" || !slug.trim()) throw new ValidationError("slug required");
|
|
106
|
+
return slug.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function relayTeamTemplateTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
110
|
+
if (name === "relay_team_template_save") {
|
|
111
|
+
requireScope(auth, "teams:write");
|
|
112
|
+
return saveTeamTemplate(args, auth.actor) as unknown as Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
if (name === "relay_team_template_list") return { templates: listTeamTemplates() };
|
|
115
|
+
if (name === "relay_team_template_get") {
|
|
116
|
+
const template = getTeamTemplate(slugArg(args));
|
|
117
|
+
if (!template) throw new ValidationError("team template not found");
|
|
118
|
+
return { template };
|
|
119
|
+
}
|
|
120
|
+
if (name === "relay_team_template_delete") {
|
|
121
|
+
requireScope(auth, "teams:write");
|
|
122
|
+
return { slug: slugArg(args), deleted: deleteTeamTemplate(slugArg(args), auth.actor) };
|
|
123
|
+
}
|
|
124
|
+
if (name === "relay_team_spawn_from_template") {
|
|
125
|
+
requireScope(auth, "teams:write");
|
|
126
|
+
return await instantiateTeamTemplate({ ...args, slug: slugArg(args) }, authContextFromMcp(auth)) as unknown as Record<string, unknown>;
|
|
127
|
+
}
|
|
128
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isTeamTemplateTool(name: string): boolean {
|
|
132
|
+
return name.startsWith("relay_team_template_") || name === "relay_team_spawn_from_template";
|
|
133
|
+
}
|
package/src/mcp-team-tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ValidationError } from "./db";
|
|
|
4
4
|
import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
|
|
5
5
|
import { cleanString, optionalEnum } from "./validation";
|
|
6
6
|
import type { Team } from "./types";
|
|
7
|
+
import { TEAM_TEMPLATE_TOOLS, isTeamTemplateTool, relayTeamTemplateTool } from "./mcp-team-template-tools";
|
|
7
8
|
|
|
8
9
|
const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
|
|
9
10
|
|
|
@@ -46,6 +47,7 @@ export const TEAM_TOOLS = [
|
|
|
46
47
|
additionalProperties: false,
|
|
47
48
|
},
|
|
48
49
|
},
|
|
50
|
+
...TEAM_TEMPLATE_TOOLS,
|
|
49
51
|
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
50
52
|
|
|
51
53
|
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
@@ -56,7 +58,8 @@ function mapTeamError(error: unknown): never {
|
|
|
56
58
|
throw error;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown
|
|
61
|
+
export async function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
62
|
+
if (isTeamTemplateTool(name)) return await relayTeamTemplateTool(auth, name, args);
|
|
60
63
|
const ctx = authContextFromMcp(auth);
|
|
61
64
|
try {
|
|
62
65
|
if (name === "relay_team_get") {
|
package/src/mcp.ts
CHANGED
|
@@ -49,21 +49,17 @@ import { ServiceAuthError } from "./services/errors";
|
|
|
49
49
|
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
|
-
import
|
|
52
|
+
import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
|
|
53
|
+
import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
53
54
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
54
55
|
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
55
56
|
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
56
57
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
57
|
-
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
58
|
+
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
58
59
|
|
|
59
60
|
type JsonRpcId = string | number | null;
|
|
60
61
|
|
|
61
|
-
type JsonRpcRequest = {
|
|
62
|
-
jsonrpc?: "2.0";
|
|
63
|
-
id?: JsonRpcId;
|
|
64
|
-
method?: string;
|
|
65
|
-
params?: unknown;
|
|
66
|
-
};
|
|
62
|
+
type JsonRpcRequest = { jsonrpc?: "2.0"; id?: JsonRpcId; method?: string; params?: unknown };
|
|
67
63
|
|
|
68
64
|
type McpAuthContext = {
|
|
69
65
|
actor: string;
|
|
@@ -92,8 +88,6 @@ const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive",
|
|
|
92
88
|
const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
|
|
93
89
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
94
90
|
const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep", "channelEvent"] as const;
|
|
95
|
-
|
|
96
|
-
|
|
97
91
|
const TOOLS: ToolDefinition[] = [
|
|
98
92
|
{
|
|
99
93
|
name: "relay_send_message",
|
|
@@ -320,6 +314,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
320
314
|
providerArgs: { type: "array", items: { type: "string" } },
|
|
321
315
|
policyName: { type: "string" },
|
|
322
316
|
spawnRequestId: { type: "string" },
|
|
317
|
+
lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
|
|
323
318
|
workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
|
|
324
319
|
waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
|
|
325
320
|
},
|
|
@@ -355,6 +350,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
355
350
|
additionalProperties: false,
|
|
356
351
|
},
|
|
357
352
|
},
|
|
353
|
+
AGENT_ACTION_TOOL,
|
|
358
354
|
{
|
|
359
355
|
name: "relay_workspace_status",
|
|
360
356
|
description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes — use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' → merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to the workspace you own; pass workspaceId to target another.",
|
|
@@ -566,6 +562,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
566
562
|
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
567
563
|
else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
|
|
568
564
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
565
|
+
else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
|
|
569
566
|
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
570
567
|
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
571
568
|
else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
|
|
@@ -573,7 +570,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
573
570
|
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
574
571
|
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
575
572
|
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
576
|
-
else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
|
|
573
|
+
else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
|
|
577
574
|
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
578
575
|
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
579
576
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
@@ -866,6 +863,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
|
|
|
866
863
|
effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
|
|
867
864
|
approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
868
865
|
workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
866
|
+
lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
|
|
869
867
|
label: optionalString(args.label, "label", 120),
|
|
870
868
|
policyName: optionalString(args.policyName, "policyName", 120),
|
|
871
869
|
profile: optionalString(args.profile, "profile", 120),
|
package/src/routes/index.ts
CHANGED
|
@@ -31,8 +31,9 @@ import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
|
|
|
31
31
|
import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
|
|
32
32
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
33
33
|
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
|
|
34
|
-
import { getMyTeamRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
34
|
+
import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
35
35
|
import { postTeamDissolveByIdRoute } from "./teams";
|
|
36
|
+
import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
|
|
36
37
|
import { postMcp } from "../mcp";
|
|
37
38
|
import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
|
|
38
39
|
|
|
@@ -139,10 +140,16 @@ const routes: Route[] = [
|
|
|
139
140
|
|
|
140
141
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
141
142
|
route("GET", "/api/teams", getTeamsRoute),
|
|
143
|
+
route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
|
|
142
144
|
route("GET", "/api/teams/me", getMyTeamRoute),
|
|
143
145
|
route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
|
|
144
146
|
route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
|
|
145
147
|
route("POST", "/api/teams/:id/dissolve", postTeamDissolveByIdRoute),
|
|
148
|
+
route("GET", "/api/team-templates", getTeamTemplatesRoute),
|
|
149
|
+
route("POST", "/api/team-templates", postTeamTemplateRoute),
|
|
150
|
+
route("GET", "/api/team-templates/:slug", getTeamTemplateRoute),
|
|
151
|
+
route("PUT", "/api/team-templates/:slug", putTeamTemplateRoute),
|
|
152
|
+
route("DELETE", "/api/team-templates/:slug", deleteTeamTemplateRoute),
|
|
146
153
|
route("GET", "/api/recipes", getRecipes),
|
|
147
154
|
route("POST", "/api/recipes/start", postRecipeStart),
|
|
148
155
|
route("GET", "/api/recipes/instances", getRecipeInstances),
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
2
|
+
import { deleteTeamTemplate, getTeamTemplate, listTeamTemplates, saveTeamTemplate } from "../services/team-templates";
|
|
3
|
+
import { cleanString } from "../validation";
|
|
4
|
+
import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
|
|
5
|
+
|
|
6
|
+
function updatedBy(req: Request): string {
|
|
7
|
+
return authContextFromRequest(req).actor.id;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function routeError(err: unknown): Response {
|
|
11
|
+
if (err instanceof Error) return error(err.message);
|
|
12
|
+
return error("team template route failed");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function slugParam(params: Record<string, string | undefined>): string {
|
|
16
|
+
return cleanString(params.slug, "slug", { required: true, max: 120 })!;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const getTeamTemplatesRoute: Handler = (req) => {
|
|
20
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
21
|
+
if (denied) return denied;
|
|
22
|
+
return json({ templates: listTeamTemplates() });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getTeamTemplateRoute: Handler = (req, params) => {
|
|
26
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
27
|
+
if (denied) return denied;
|
|
28
|
+
try {
|
|
29
|
+
const template = getTeamTemplate(slugParam(params));
|
|
30
|
+
return template ? json(template) : error("team template not found", 404);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return routeError(err);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const postTeamTemplateRoute: Handler = async (req) => {
|
|
37
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
38
|
+
if (denied) return denied;
|
|
39
|
+
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
40
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
41
|
+
try {
|
|
42
|
+
return json(saveTeamTemplate(parsed.body ?? {}, updatedBy(req)), 201);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return routeError(err);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const putTeamTemplateRoute: Handler = async (req, params) => {
|
|
49
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
50
|
+
if (denied) return denied;
|
|
51
|
+
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
52
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
53
|
+
try {
|
|
54
|
+
return json(saveTeamTemplate({ ...(parsed.body ?? {}), slug: slugParam(params) }, updatedBy(req)));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return routeError(err);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const deleteTeamTemplateRoute: Handler = (req, params) => {
|
|
61
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
62
|
+
if (denied) return denied;
|
|
63
|
+
const slug = slugParam(params);
|
|
64
|
+
return json({ slug, deleted: deleteTeamTemplate(slug, updatedBy(req)) });
|
|
65
|
+
};
|
package/src/routes/teams.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
2
2
|
import { authContextFromRequest } from "../services/auth-context";
|
|
3
|
-
import { getTeam, listAllTeams, ValidationError } from "../db";
|
|
3
|
+
import { getTeam, getTeamOutcomeAggregates, listAllTeams, ValidationError } from "../db";
|
|
4
4
|
import { dissolveCallerTeam, dissolveTeamById, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
|
|
5
5
|
import { cleanString, optionalEnum } from "../validation";
|
|
6
|
-
import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
|
|
6
|
+
import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
7
7
|
import type { Team } from "../types";
|
|
8
8
|
|
|
9
9
|
const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
|
|
@@ -50,6 +50,18 @@ export const getTeamsRoute: Handler = (req) => {
|
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
export const getTeamOutcomesRoute: Handler = (req) => {
|
|
54
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
55
|
+
if (denied) return denied;
|
|
56
|
+
try {
|
|
57
|
+
const recentLimit = parseQueryInt(new URL(req.url).searchParams.get("recentLimit"), { min: 1, max: 50, clamp: true }) ?? undefined;
|
|
58
|
+
if (Number.isNaN(recentLimit)) return error("invalid recentLimit", 400);
|
|
59
|
+
return json(getTeamOutcomeAggregates({ recentLimit }));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return routeError(err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
53
65
|
export const putMyTeamBriefRoute: Handler = async (req) => {
|
|
54
66
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
55
67
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|