agent-relay-server 0.78.9 → 0.80.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 +153 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-NWZehv71.js → MessageBubble-C-3IcqgU.js} +2 -2
- package/public/assets/{MessageBubble-NWZehv71.js.map → MessageBubble-C-3IcqgU.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-Dt3AZz4B.js → PlanGraphPanel-o3u36Del.js} +2 -2
- package/public/assets/{PlanGraphPanel-Dt3AZz4B.js.map → PlanGraphPanel-o3u36Del.js.map} +1 -1
- package/public/assets/{activity-Bo5DgiCq.js → activity-L3POfhGa.js} +2 -2
- package/public/assets/{activity-Bo5DgiCq.js.map → activity-L3POfhGa.js.map} +1 -1
- package/public/assets/{agent-profiles-_cAkMjLb.js → agent-profiles-CeoqyRis.js} +2 -2
- package/public/assets/{agent-profiles-_cAkMjLb.js.map → agent-profiles-CeoqyRis.js.map} +1 -1
- package/public/assets/{agents-DWSugBVH.js → agents-DAdDtQb6.js} +2 -2
- package/public/assets/{agents-DWSugBVH.js.map → agents-DAdDtQb6.js.map} +1 -1
- package/public/assets/{analytics-DLt_3X2u.js → analytics-B6wGSTf3.js} +2 -2
- package/public/assets/{analytics-DLt_3X2u.js.map → analytics-B6wGSTf3.js.map} +1 -1
- package/public/assets/{automation-CeWlkojL.js → automation-kakk3xZ9.js} +2 -2
- package/public/assets/{automation-CeWlkojL.js.map → automation-kakk3xZ9.js.map} +1 -1
- package/public/assets/{branch-state-badge-DbJzvZw0.js → branch-state-badge-BSwkl9A8.js} +2 -2
- package/public/assets/{branch-state-badge-DbJzvZw0.js.map → branch-state-badge-BSwkl9A8.js.map} +1 -1
- package/public/assets/{channels-D8T7P8ui.js → channels-BzY-_4Zi.js} +2 -2
- package/public/assets/{channels-D8T7P8ui.js.map → channels-BzY-_4Zi.js.map} +1 -1
- package/public/assets/{chat-Bt1j5pmY.js → chat-DDlRjwGC.js} +2 -2
- package/public/assets/{chat-Bt1j5pmY.js.map → chat-DDlRjwGC.js.map} +1 -1
- package/public/assets/{connectors-BTjbCi5q.js → connectors-empwp8UF.js} +2 -2
- package/public/assets/{connectors-BTjbCi5q.js.map → connectors-empwp8UF.js.map} +1 -1
- package/public/assets/display-DHgdn4vN.js.map +1 -1
- package/public/assets/{formatted-body-impl-gaw1gO1I.js → formatted-body-impl-CEqSWNRL.js} +2 -2
- package/public/assets/{formatted-body-impl-gaw1gO1I.js.map → formatted-body-impl-CEqSWNRL.js.map} +1 -1
- package/public/assets/{index-CgHPA8OX.js → index-CtGaz-Yg.js} +6 -6
- package/public/assets/index-CtGaz-Yg.js.map +1 -0
- package/public/assets/{insights-CPQpfNUK.js → insights-mf5sd483.js} +2 -2
- package/public/assets/{insights-CPQpfNUK.js.map → insights-mf5sd483.js.map} +1 -1
- package/public/assets/{integrations-JEkQvc-S.js → integrations-4V9lOQ8b.js} +2 -2
- package/public/assets/{integrations-JEkQvc-S.js.map → integrations-4V9lOQ8b.js.map} +1 -1
- package/public/assets/{maintenance-D6c_hvMO.js → maintenance-CruMfiA7.js} +2 -2
- package/public/assets/{maintenance-D6c_hvMO.js.map → maintenance-CruMfiA7.js.map} +1 -1
- package/public/assets/{managed-agents-DmSmdhRe.js → managed-agents-fOhHGzNI.js} +2 -2
- package/public/assets/{managed-agents-DmSmdhRe.js.map → managed-agents-fOhHGzNI.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DbE1Axo2.js → markdown-preview-impl-g5-PTM1A.js} +2 -2
- package/public/assets/{markdown-preview-impl-DbE1Axo2.js.map → markdown-preview-impl-g5-PTM1A.js.map} +1 -1
- package/public/assets/{memory-B7SuFMg_.js → memory-CRyq2WNC.js} +2 -2
- package/public/assets/{memory-B7SuFMg_.js.map → memory-CRyq2WNC.js.map} +1 -1
- package/public/assets/{messages-C19vhh1D.js → messages-BqLp0X3y.js} +2 -2
- package/public/assets/{messages-C19vhh1D.js.map → messages-BqLp0X3y.js.map} +1 -1
- package/public/assets/{orchestrators-Dw2QF8-1.js → orchestrators-BD4nZcG3.js} +2 -2
- package/public/assets/{orchestrators-Dw2QF8-1.js.map → orchestrators-BD4nZcG3.js.map} +1 -1
- package/public/assets/{overview-DRDCQYId.js → overview-De-2GN06.js} +2 -2
- package/public/assets/{overview-DRDCQYId.js.map → overview-De-2GN06.js.map} +1 -1
- package/public/assets/{pairs-CeBqU3ev.js → pairs-DOqjBd0R.js} +2 -2
- package/public/assets/{pairs-CeBqU3ev.js.map → pairs-DOqjBd0R.js.map} +1 -1
- package/public/assets/{projects-CoJjeTUE.js → projects-Byp_gLoD.js} +2 -2
- package/public/assets/{projects-CoJjeTUE.js.map → projects-Byp_gLoD.js.map} +1 -1
- package/public/assets/{security-2o1oGQ9C.js → security-DtoRSpIi.js} +2 -2
- package/public/assets/{security-2o1oGQ9C.js.map → security-DtoRSpIi.js.map} +1 -1
- package/public/assets/{select-CYX4uPPQ.js → select-esP750zQ.js} +2 -2
- package/public/assets/{select-CYX4uPPQ.js.map → select-esP750zQ.js.map} +1 -1
- package/public/assets/{settings-Dk8JFQ4C.js → settings-U8788lYt.js} +2 -2
- package/public/assets/{settings-Dk8JFQ4C.js.map → settings-U8788lYt.js.map} +1 -1
- package/public/assets/{store-m7GK-CMC.js → store-Cjcd0egy.js} +2 -2
- package/public/assets/store-Cjcd0egy.js.map +1 -0
- package/public/assets/{tasks-ByWEnbEl.js → tasks-CtiwQy9I.js} +2 -2
- package/public/assets/{tasks-ByWEnbEl.js.map → tasks-CtiwQy9I.js.map} +1 -1
- package/public/assets/{teams-KsfHMaNk.js → teams-Cx29VUif.js} +2 -2
- package/public/assets/{teams-KsfHMaNk.js.map → teams-Cx29VUif.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BAb6QYYF.js → terminal-viewer-impl-h9hvvB9S.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BAb6QYYF.js.map → terminal-viewer-impl-h9hvvB9S.js.map} +1 -1
- package/public/assets/{work-queue-DI9rr47X.js → work-queue-C-bS6YmI.js} +2 -2
- package/public/assets/{work-queue-DI9rr47X.js.map → work-queue-C-bS6YmI.js.map} +1 -1
- package/public/assets/{workspaces-DAuU-uMo.js → workspaces-Bivhqw2N.js} +2 -2
- package/public/assets/{workspaces-DAuU-uMo.js.map → workspaces-Bivhqw2N.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/workspace.ts +26 -3
- package/src/config-store.ts +1 -1
- package/src/db/provider-quotas.ts +3 -1
- package/src/db/teams.ts +18 -0
- package/src/maintenance/jobs.ts +9 -1
- package/src/maintenance/teams.ts +122 -0
- package/src/notification-types.ts +2 -0
- package/src/quota.ts +1 -1
- package/src/routes/index.ts +6 -1
- package/src/routes/provider-quotas.ts +4 -1
- package/src/routes/teams-config.ts +15 -0
- package/src/routes/workspaces.ts +144 -1
- package/src/security.ts +3 -1
- package/src/settings/registry.ts +27 -0
- package/src/teams-config-store.ts +22 -0
- package/public/assets/index-CgHPA8OX.js.map +0 -1
- package/public/assets/store-m7GK-CMC.js.map +0 -1
package/src/db/teams.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getAgent, setAgentTeam } from "./agents.ts";
|
|
|
3
3
|
import { getDb, ValidationError } from "./connection.ts";
|
|
4
4
|
import { rowToAgent } from "./mappers.ts";
|
|
5
5
|
import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
|
|
6
|
+
import { isProviderBlocked } from "../provider-state.ts";
|
|
6
7
|
import type { AgentCard, Team } from "../types";
|
|
7
8
|
|
|
8
9
|
interface TeamRow {
|
|
@@ -72,6 +73,11 @@ export interface TeamOutcomeAggregates {
|
|
|
72
73
|
recent: TeamOutcomeRecent[];
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
interface TeamBusyState {
|
|
77
|
+
busyMemberIds: string[];
|
|
78
|
+
lastBusyAt?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
export function teamId(): string {
|
|
76
82
|
return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
77
83
|
}
|
|
@@ -132,6 +138,18 @@ export function countLiveTeamMembers(teamId: string, cutoff: number): number {
|
|
|
132
138
|
return row.n;
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
export function getTeamBusyState(teamId: string, cutoff: number): TeamBusyState {
|
|
142
|
+
const rows = (getDb().query(
|
|
143
|
+
"SELECT * FROM agents WHERE team_id = ? AND status = 'busy' AND last_seen > ? ORDER BY last_seen DESC, id ASC",
|
|
144
|
+
).all(teamId, cutoff) as any[])
|
|
145
|
+
.map(rowToAgent)
|
|
146
|
+
.filter((agent) => !isProviderBlocked(agent));
|
|
147
|
+
return {
|
|
148
|
+
busyMemberIds: rows.map((row) => row.id),
|
|
149
|
+
...(rows[0] ? { lastBusyAt: rows[0].lastSeen } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
export function setTeamZeroMembersSince(teamId: string, ts: number): boolean {
|
|
136
154
|
return getDb().query("UPDATE teams SET zero_members_since = ?, updated_at = ? WHERE id = ?").run(ts, ts, teamId).changes > 0;
|
|
137
155
|
}
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
} from "./constants";
|
|
52
52
|
import { emitCommand } from "./events";
|
|
53
53
|
import { reapOrphanedSessions } from "./orphaned-sessions";
|
|
54
|
-
import { reapZeroMemberTeams } from "./teams";
|
|
54
|
+
import { reapZeroMemberTeams, runTeamIdleWatchdog } from "./teams";
|
|
55
55
|
import { scanWorkspaceConflicts, workspaceGC } from "./workspaces";
|
|
56
56
|
import type { MaintenanceJobDefinition } from "./types";
|
|
57
57
|
|
|
@@ -208,6 +208,14 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
208
208
|
runOnStart: false,
|
|
209
209
|
handler: reapZeroMemberTeams,
|
|
210
210
|
},
|
|
211
|
+
{
|
|
212
|
+
id: "team-idle-watchdog",
|
|
213
|
+
title: "Team idle watchdog",
|
|
214
|
+
description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
|
|
215
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
216
|
+
runOnStart: false,
|
|
217
|
+
handler: runTeamIdleWatchdog,
|
|
218
|
+
},
|
|
211
219
|
{
|
|
212
220
|
id: "orchestrator-reaper",
|
|
213
221
|
title: "Orchestrator reaper",
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -2,12 +2,134 @@ import {
|
|
|
2
2
|
clearTeamZeroMembersSince,
|
|
3
3
|
countLiveTeamMembers,
|
|
4
4
|
createActivityEvent,
|
|
5
|
+
getTeamBusyState,
|
|
6
|
+
getTeamRoster,
|
|
5
7
|
listActiveTeams,
|
|
6
8
|
setTeamZeroMembersSince,
|
|
7
9
|
} from "../db";
|
|
8
10
|
import { STALE_TTL_MS } from "../config";
|
|
11
|
+
import { agentProviderState } from "../provider-state";
|
|
9
12
|
import { dissolveTeamById } from "../services/team";
|
|
13
|
+
import { getTeamsConfig } from "../teams-config-store";
|
|
10
14
|
import { teamReapGraceMs } from "./constants";
|
|
15
|
+
import { notifyTarget } from "./events";
|
|
16
|
+
|
|
17
|
+
interface TeamIdleTrackerEntry {
|
|
18
|
+
firstNoBusyAt: number;
|
|
19
|
+
pinged: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const teamIdleTracker = new Map<string, TeamIdleTrackerEntry>();
|
|
23
|
+
|
|
24
|
+
export function resetTeamIdleWatchdogForTests(): void {
|
|
25
|
+
teamIdleTracker.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
|
|
29
|
+
const roster = getTeamRoster(teamId).map((member) => {
|
|
30
|
+
const providerState = agentProviderState(member);
|
|
31
|
+
return {
|
|
32
|
+
id: member.id,
|
|
33
|
+
name: member.name,
|
|
34
|
+
label: member.label,
|
|
35
|
+
status: member.status,
|
|
36
|
+
ready: member.ready,
|
|
37
|
+
lastSeen: member.lastSeen,
|
|
38
|
+
blocked: providerState?.state === "blocked",
|
|
39
|
+
providerState: providerState ?? undefined,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
kind: "team.idle",
|
|
44
|
+
teamId,
|
|
45
|
+
coordinatorId,
|
|
46
|
+
thresholdMinutes,
|
|
47
|
+
thresholdMs: thresholdMinutes * 60_000,
|
|
48
|
+
idleForMs,
|
|
49
|
+
firstNoBusyAt,
|
|
50
|
+
roster,
|
|
51
|
+
blockedMembers: roster.filter((member) => member.blocked).map((member) => ({
|
|
52
|
+
id: member.id,
|
|
53
|
+
providerState: member.providerState,
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function notifyTeamIdle(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number): string | null {
|
|
59
|
+
const roundedIdleMinutes = Math.max(thresholdMinutes, Math.floor(idleForMs / 60_000));
|
|
60
|
+
const payload = teamIdlePayload(teamId, coordinatorId, thresholdMinutes, idleForMs, firstNoBusyAt);
|
|
61
|
+
return notifyTarget(
|
|
62
|
+
"team.idle",
|
|
63
|
+
{ teamId, agentId: coordinatorId },
|
|
64
|
+
coordinatorId,
|
|
65
|
+
"Team idle watchdog",
|
|
66
|
+
`Team \`${teamId}\` has had no busy members for ${roundedIdleMinutes} min. If the work is done, stand down. If not, check the team roster and restart unfinished work.`,
|
|
67
|
+
payload,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string, unknown> {
|
|
72
|
+
const config = getTeamsConfig();
|
|
73
|
+
if (!config.enabled) {
|
|
74
|
+
const cleared = teamIdleTracker.size;
|
|
75
|
+
teamIdleTracker.clear();
|
|
76
|
+
return { enabled: false, clearedTrackedTeams: cleared };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const now = input.now ?? Date.now();
|
|
80
|
+
const cutoff = now - STALE_TTL_MS;
|
|
81
|
+
const thresholdMs = config.thresholdMinutes * 60_000;
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
const busyTeamIds: string[] = [];
|
|
84
|
+
const waitingTeamIds: string[] = [];
|
|
85
|
+
const pingedTeamIds: string[] = [];
|
|
86
|
+
const alreadyPingedTeamIds: string[] = [];
|
|
87
|
+
const missingCoordinatorTeamIds: string[] = [];
|
|
88
|
+
|
|
89
|
+
for (const team of listActiveTeams()) {
|
|
90
|
+
seen.add(team.id);
|
|
91
|
+
const busy = getTeamBusyState(team.id, cutoff);
|
|
92
|
+
if (busy.busyMemberIds.length > 0) {
|
|
93
|
+
teamIdleTracker.delete(team.id);
|
|
94
|
+
busyTeamIds.push(team.id);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const entry = teamIdleTracker.get(team.id) ?? { firstNoBusyAt: now, pinged: false };
|
|
99
|
+
teamIdleTracker.set(team.id, entry);
|
|
100
|
+
const idleForMs = now - entry.firstNoBusyAt;
|
|
101
|
+
if (idleForMs < thresholdMs) {
|
|
102
|
+
waitingTeamIds.push(team.id);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (entry.pinged) {
|
|
107
|
+
alreadyPingedTeamIds.push(team.id);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!team.coordinatorId) {
|
|
111
|
+
missingCoordinatorTeamIds.push(team.id);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
notifyTeamIdle(team.id, team.coordinatorId, config.thresholdMinutes, idleForMs, entry.firstNoBusyAt);
|
|
116
|
+
entry.pinged = true;
|
|
117
|
+
pingedTeamIds.push(team.id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
enabled: true,
|
|
124
|
+
thresholdMinutes: config.thresholdMinutes,
|
|
125
|
+
trackedTeams: teamIdleTracker.size,
|
|
126
|
+
busyTeamIds,
|
|
127
|
+
waitingTeamIds,
|
|
128
|
+
pingedTeamIds,
|
|
129
|
+
alreadyPingedTeamIds,
|
|
130
|
+
missingCoordinatorTeamIds,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
11
133
|
|
|
12
134
|
export function reapZeroMemberTeams(): Record<string, unknown> {
|
|
13
135
|
const now = Date.now();
|
|
@@ -10,6 +10,7 @@ export type NotificationType =
|
|
|
10
10
|
| "agent.resume_budget_exhausted"
|
|
11
11
|
| "agent.transient_land_hold"
|
|
12
12
|
| "agent.rate_limit_resume"
|
|
13
|
+
| "team.idle"
|
|
13
14
|
| "plan.bound_agent_exited"
|
|
14
15
|
| "workspace.steward_task"
|
|
15
16
|
| "workspace.conflict"
|
|
@@ -60,6 +61,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
60
61
|
"agent.resume_budget_exhausted": null,
|
|
61
62
|
"agent.transient_land_hold": null,
|
|
62
63
|
"agent.rate_limit_resume": 15 * MINUTES,
|
|
64
|
+
"team.idle": null,
|
|
63
65
|
"plan.bound_agent_exited": null,
|
|
64
66
|
"workspace.steward_task": null,
|
|
65
67
|
"workspace.conflict": null,
|
package/src/quota.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
2
|
import type { ProviderQuotaError, QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
|
|
3
3
|
|
|
4
|
-
const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest"]);
|
|
4
|
+
const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline"]);
|
|
5
5
|
const QUOTA_UNITS = new Set<QuotaUnit>(["tokens", "requests", "%"]);
|
|
6
6
|
const QUOTA_WINDOWS = new Set<QuotaWindowName>(["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus", "primary", "secondary"]);
|
|
7
7
|
|
package/src/routes/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
|
|
|
7
7
|
import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
|
|
8
8
|
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
9
9
|
import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
|
|
10
|
+
import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
|
|
10
11
|
import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
|
|
11
12
|
import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
|
|
12
13
|
import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
|
|
@@ -14,7 +15,7 @@ import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRo
|
|
|
14
15
|
import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
|
|
15
16
|
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
16
17
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
17
|
-
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim } from "./workspaces";
|
|
18
|
+
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
18
19
|
import { error, type Handler } from "./_shared";
|
|
19
20
|
import { getActivityEvents, postActivityEvent } from "./activity";
|
|
20
21
|
import { getAnalyticsRoute, getHealthRoute, getMaintenanceJobs, getStatsRoute, postMaintenanceJobRun, postSystemReap } from "./stats";
|
|
@@ -142,6 +143,8 @@ const routes: Route[] = [
|
|
|
142
143
|
// Static segments before :id so "/workspaces/orphans" isn't captured as an id.
|
|
143
144
|
route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
|
|
144
145
|
route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
|
|
146
|
+
route("GET", "/api/workspaces/recovery-branches", getWorkspaceRecoveryBranches),
|
|
147
|
+
route("POST", "/api/workspaces/recovery-branches/discard", postWorkspaceRecoveryBranchDiscard),
|
|
145
148
|
route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
|
|
146
149
|
route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
|
|
147
150
|
route("GET", "/api/workspaces/:id", getWorkspaceById),
|
|
@@ -159,6 +162,8 @@ const routes: Route[] = [
|
|
|
159
162
|
route("GET", "/api/merges/pauses/:id", getMergePauseById),
|
|
160
163
|
|
|
161
164
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
165
|
+
route("GET", "/api/teams/config", getTeamsConfigRoute),
|
|
166
|
+
route("PUT", "/api/teams/config", putTeamsConfigRoute),
|
|
162
167
|
route("GET", "/api/teams", getTeamsRoute),
|
|
163
168
|
route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
|
|
164
169
|
route("GET", "/api/teams/me", getMyTeamRoute),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
2
|
import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
|
|
3
|
+
import { emitRelayEvent } from "../events";
|
|
3
4
|
import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
|
|
4
5
|
import { cleanString } from "../validation";
|
|
5
6
|
import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
@@ -44,7 +45,9 @@ export const postProviderQuotaRoute: Handler = async (req) => {
|
|
|
44
45
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
45
46
|
const input = cleanProviderQuotaUpdate(parsed.body);
|
|
46
47
|
if (typeof input === "string") return error(input);
|
|
47
|
-
|
|
48
|
+
const record = upsertProviderQuota(input);
|
|
49
|
+
emitRelayEvent({ type: "provider-quota.updated", source: "server", subject: `${record.provider}:${record.accountKey}`, data: {} });
|
|
50
|
+
return json(record, 201);
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
function cleanLeaseBody(body: unknown): {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getTeamsConfig, setTeamsConfig } from "../teams-config-store";
|
|
2
|
+
import { cleanString } from "../validation";
|
|
3
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
4
|
+
import { isRecord } from "agent-relay-sdk";
|
|
5
|
+
|
|
6
|
+
export const getTeamsConfigRoute: Handler = () => {
|
|
7
|
+
return json(getTeamsConfig());
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const putTeamsConfigRoute: Handler = async (req) => {
|
|
11
|
+
const parsed = await parseBody<unknown>(req);
|
|
12
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
13
|
+
const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
14
|
+
return json(setTeamsConfig(parsed.body, { updatedBy }));
|
|
15
|
+
};
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createCommand } from "../commands-db";
|
|
|
10
10
|
import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
|
|
11
11
|
import { isPathWithinBase } from "../utils";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
13
|
+
import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
14
14
|
import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
15
15
|
import { workspaceActiveClaim } from "../workspace-claim";
|
|
16
16
|
import { relayToken } from "../config";
|
|
@@ -82,6 +82,149 @@ export const getWorkspaceOrphans: Handler = async () => {
|
|
|
82
82
|
return json(reason ? { orphans, missingWorktrees, reason } : { orphans, missingWorktrees });
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
function workspaceCleanupResult(workspace: WorkspaceRecord): Record<string, unknown> | undefined {
|
|
86
|
+
const result = workspace.metadata.cleanupResult;
|
|
87
|
+
return isRecord(result) ? result : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function workspaceBranchKey(repoRoot: string, branch: string): string {
|
|
91
|
+
return `${repoRoot}\0${branch}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, WorkspaceRecord> {
|
|
95
|
+
const byBranch = new Map<string, WorkspaceRecord>();
|
|
96
|
+
for (const workspace of [...workspaces].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
|
97
|
+
if (!workspace.branch) continue;
|
|
98
|
+
const key = workspaceBranchKey(workspace.repoRoot, workspace.branch);
|
|
99
|
+
if (byBranch.has(key)) continue;
|
|
100
|
+
byBranch.set(key, workspace);
|
|
101
|
+
}
|
|
102
|
+
return byBranch;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function owningOrchestratorForRepo(repoRoot: string) {
|
|
106
|
+
return listOrchestrators().find((candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
|
|
110
|
+
const orch = owningOrchestratorForRepo(repoRoot);
|
|
111
|
+
if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
|
|
112
|
+
const query = new URLSearchParams({ repoRoot });
|
|
113
|
+
if (opts.baseRef) query.set("baseRef", opts.baseRef);
|
|
114
|
+
if (opts.baseSha) query.set("baseSha", opts.baseSha);
|
|
115
|
+
const headers: Record<string, string> = {};
|
|
116
|
+
const token = relayToken();
|
|
117
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/recovery-branches?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
|
|
120
|
+
if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
|
|
121
|
+
const payload = await res.json() as unknown;
|
|
122
|
+
if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
|
|
123
|
+
return { branches: payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[] };
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const getWorkspaceRecoveryBranches: Handler = async (req) => {
|
|
130
|
+
try {
|
|
131
|
+
const url = new URL(req.url);
|
|
132
|
+
const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
|
|
133
|
+
const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
|
|
134
|
+
const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
|
|
135
|
+
const includeActive = url.searchParams.get("includeActive") === "1";
|
|
136
|
+
const allWorkspaces = listWorkspaces(repoRoot ? { repoRoot } : {});
|
|
137
|
+
const repoRoots = repoRoot ? [repoRoot] : [...new Set(allWorkspaces.map((workspace) => workspace.repoRoot))];
|
|
138
|
+
const byBranch = latestWorkspaceByBranch(allWorkspaces);
|
|
139
|
+
const branches: WorkspaceRecoveryBranch[] = [];
|
|
140
|
+
const unavailable: Record<string, string> = {};
|
|
141
|
+
|
|
142
|
+
for (const repo of repoRoots) {
|
|
143
|
+
const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
|
|
144
|
+
if (fetched.unavailable) {
|
|
145
|
+
unavailable[repo] = fetched.unavailable;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const branch of fetched.branches) {
|
|
149
|
+
const workspace = byBranch.get(workspaceBranchKey(repo, branch.branch));
|
|
150
|
+
const activeWorkspace = Boolean(workspace && !TERMINAL_WORKSPACE_STATUSES.has(workspace.status));
|
|
151
|
+
if (activeWorkspace && !includeActive) continue;
|
|
152
|
+
const owner = workspace?.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
|
|
153
|
+
const cleanupResult = workspace ? workspaceCleanupResult(workspace) : undefined;
|
|
154
|
+
const cleanupPreservedReason = cleanString(cleanupResult?.branchPreservedReason, "branchPreservedReason", { max: 400 });
|
|
155
|
+
const cleanupCommandId = cleanString(workspace?.metadata.cleanupCommandId, "cleanupCommandId", { max: 160 });
|
|
156
|
+
branches.push({
|
|
157
|
+
...branch,
|
|
158
|
+
...(workspace ? { workspaceId: workspace.id } : {}),
|
|
159
|
+
...(workspace?.ownerAgentId ? { ownerAgentId: workspace.ownerAgentId } : {}),
|
|
160
|
+
...(owner?.status ? { ownerStatus: owner.status } : {}),
|
|
161
|
+
...(cleanupCommandId ? { cleanupCommandId } : {}),
|
|
162
|
+
...(cleanupPreservedReason ? { cleanupPreservedReason, preservedByCleanup: true } : {}),
|
|
163
|
+
...(activeWorkspace ? { activeWorkspace: true } : {}),
|
|
164
|
+
baseRef: workspace?.baseRef ?? branch.baseRef,
|
|
165
|
+
baseSha: workspace?.baseSha ?? branch.baseSha,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
branches.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0) || a.branch.localeCompare(b.branch));
|
|
171
|
+
return json({ repoRoot, scannedRepos: repoRoots, branches, ...(Object.keys(unavailable).length ? { unavailable } : {}) });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
|
|
179
|
+
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
180
|
+
if (denied) return denied;
|
|
181
|
+
const parsed = await parseBody<unknown>(req);
|
|
182
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
183
|
+
try {
|
|
184
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
185
|
+
const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
|
|
186
|
+
const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
|
|
187
|
+
const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
|
|
188
|
+
const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
|
|
189
|
+
const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
|
|
190
|
+
const force = parsed.body.force === true;
|
|
191
|
+
if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
|
|
192
|
+
if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
|
|
193
|
+
if (force && !reason) return error("reason required for forced recovery branch discard", 400);
|
|
194
|
+
const orch = owningOrchestratorForRepo(repoRoot);
|
|
195
|
+
if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
|
|
196
|
+
const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
|
|
197
|
+
if (fetched.unavailable) return error(fetched.unavailable, 409);
|
|
198
|
+
const target = fetched.branches.find((item) => item.branch === branch);
|
|
199
|
+
if (!target) return error("recovery branch not found", 404);
|
|
200
|
+
if (target.safeToDelete !== true && !force) {
|
|
201
|
+
return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
|
|
202
|
+
}
|
|
203
|
+
const command = createCommand({
|
|
204
|
+
type: "workspace.recovery-branch-discard",
|
|
205
|
+
source: "system",
|
|
206
|
+
target: orch.agentId,
|
|
207
|
+
correlationId: branch,
|
|
208
|
+
params: { action: "recovery-branch-discard", repoRoot, branch, baseRef: baseRef ?? target.baseRef, baseSha: baseSha ?? target.baseSha, force, reason, requestedBy: "steward", requestedAt: Date.now() },
|
|
209
|
+
});
|
|
210
|
+
emitCommand(command);
|
|
211
|
+
auditEvent({
|
|
212
|
+
clientId: `workspace-recovery-branch-discard-${Date.now()}`,
|
|
213
|
+
kind: "state",
|
|
214
|
+
title: "Recovery branch discard requested",
|
|
215
|
+
body: force ? `${branch} — forced discard after review: ${reason}` : `${branch} — confirmed empty/superseded`,
|
|
216
|
+
meta: repoRoot,
|
|
217
|
+
icon: "ti-trash",
|
|
218
|
+
view: "orchestrators",
|
|
219
|
+
metadata: { repoRoot, branch, commandId: command.id, force, reason, safeToDelete: target.safeToDelete, preserveReason: target.preserveReason, ...authAuditMetadata(req) },
|
|
220
|
+
});
|
|
221
|
+
return json({ command, branch: target }, 202);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
85
228
|
export const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
86
229
|
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
87
230
|
if (denied) return denied;
|
package/src/security.ts
CHANGED
|
@@ -194,6 +194,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
194
194
|
if (pathname.startsWith("/api/maintenance")) return "system:admin";
|
|
195
195
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
196
196
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
197
|
+
if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
|
|
197
198
|
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
198
199
|
if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
|
|
199
200
|
if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
|
|
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
280
281
|
if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
|
|
281
282
|
return method === "GET" ? "agent:read" : "settings:write:provider";
|
|
282
283
|
}
|
|
284
|
+
if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
|
|
283
285
|
if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
|
|
284
286
|
if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
|
|
285
287
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
@@ -304,7 +306,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
304
306
|
return "system:admin";
|
|
305
307
|
}
|
|
306
308
|
|
|
307
|
-
const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider"]);
|
|
309
|
+
const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider", "teams"]);
|
|
308
310
|
|
|
309
311
|
function requiredSettingsScopeFor(method: string, pathname: string): string | null | undefined {
|
|
310
312
|
if (pathname === "/api/settings") return method === "GET" ? "settings:read" : null;
|
package/src/settings/registry.ts
CHANGED
|
@@ -20,11 +20,14 @@ import {
|
|
|
20
20
|
APPROVAL_MODES,
|
|
21
21
|
errMessage,
|
|
22
22
|
SPAWN_PROVIDERS,
|
|
23
|
+
TEAM_IDLE_THRESHOLD_MINUTES_MAX,
|
|
24
|
+
TEAM_IDLE_THRESHOLD_MINUTES_MIN,
|
|
23
25
|
VALID_EFFORTS,
|
|
24
26
|
type JSONSchema,
|
|
25
27
|
type SettingDescriptor,
|
|
26
28
|
type SettingScope,
|
|
27
29
|
} from "agent-relay-sdk";
|
|
30
|
+
import { DEFAULT_TEAMS_CONFIG } from "agent-relay-sdk/teams-config";
|
|
28
31
|
|
|
29
32
|
/** Fail-closed default scope — a descriptor that omits scope requires admin to
|
|
30
33
|
* read and write (#385 owns the scope grammar; this layer only declares it). */
|
|
@@ -215,6 +218,20 @@ const WORKSPACE_SCHEMA: JSONSchema = {
|
|
|
215
218
|
},
|
|
216
219
|
};
|
|
217
220
|
|
|
221
|
+
const TEAMS_SCHEMA: JSONSchema = {
|
|
222
|
+
type: "object",
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
required: ["enabled", "thresholdMinutes"],
|
|
225
|
+
properties: {
|
|
226
|
+
enabled: { type: "boolean" },
|
|
227
|
+
thresholdMinutes: {
|
|
228
|
+
type: "integer",
|
|
229
|
+
minimum: TEAM_IDLE_THRESHOLD_MINUTES_MIN,
|
|
230
|
+
maximum: TEAM_IDLE_THRESHOLD_MINUTES_MAX,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
218
235
|
const LANDING_SCHEMA: JSONSchema = {
|
|
219
236
|
type: "object",
|
|
220
237
|
additionalProperties: false,
|
|
@@ -347,6 +364,16 @@ function seedBuiltInSettings(): void {
|
|
|
347
364
|
display: { group: "Workspace", label: "Landing strategy", description: "Default land strategy for workspace branches.", order: 41 },
|
|
348
365
|
});
|
|
349
366
|
|
|
367
|
+
registerSetting({
|
|
368
|
+
namespace: "teams",
|
|
369
|
+
key: "default",
|
|
370
|
+
schema: TEAMS_SCHEMA,
|
|
371
|
+
defaults: DEFAULT_TEAMS_CONFIG,
|
|
372
|
+
scope: { read: "settings:read:teams", write: "settings:write:teams" },
|
|
373
|
+
tier: "server-runtime",
|
|
374
|
+
display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
|
|
375
|
+
});
|
|
376
|
+
|
|
350
377
|
registerSetting({
|
|
351
378
|
namespace: "provider",
|
|
352
379
|
key: "chatCaptureMode",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getConfig, setConfig } from "./config-store";
|
|
2
|
+
import { emitConfigChanged } from "./sse";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_TEAMS_CONFIG,
|
|
5
|
+
normalizeTeamsConfig,
|
|
6
|
+
type TeamsConfig,
|
|
7
|
+
} from "agent-relay-sdk";
|
|
8
|
+
|
|
9
|
+
const TEAMS_CONFIG_NAMESPACE = "teams";
|
|
10
|
+
const TEAMS_CONFIG_KEY = "default";
|
|
11
|
+
|
|
12
|
+
export function getTeamsConfig(): TeamsConfig {
|
|
13
|
+
const entry = getConfig<TeamsConfig>(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY);
|
|
14
|
+
return entry ? normalizeTeamsConfig(entry.value) : { ...DEFAULT_TEAMS_CONFIG };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setTeamsConfig(value: unknown, opts: { updatedBy?: string } = {}): TeamsConfig {
|
|
18
|
+
const normalized = normalizeTeamsConfig(value);
|
|
19
|
+
const entry = setConfig(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY, normalized, opts.updatedBy);
|
|
20
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|