Forge
csharpdeeb25a2
1using CascadeIDE.Features.Chat;
2using CascadeIDE.Features.Intercom.Transport;
3using CascadeIDE.Models;
4using CascadeIDE.Services;
5using CascadeIDE.Services.Intercom;
6
7namespace CascadeIDE.Features.Intercom.Admin;
8
9/// <summary>Team/server admin из CIDE (ADR 0147 фазы 1–4).</summary>
10public sealed class IntercomAdminService
11{
12 private readonly IntercomTransportCoordinator _transport;
13 private readonly IntercomServerHostService _serverHost = new();
14 private readonly Func<CascadeIdeSettings> _getSettings;
15 private readonly Action _saveSettings;
16
17 public IntercomAdminService(
18 IntercomTransportCoordinator transport,
19 Func<CascadeIdeSettings> getSettings,
20 Action saveSettings)
21 {
22 _transport = transport;
23 _getSettings = getSettings;
24 _saveSettings = saveSettings;
25 }
26
27 public IntercomServerHostService ServerHost => _serverHost;
28
29 public async Task<ChatSlashIntercomResult> ExecuteAsync(
30 string handlerId,
31 string? argsTail,
32 CancellationToken ct)
33 {
34 return handlerId switch
35 {
36 ChatSlashIntercomHandlers.Ids.ServerStatus => await ServerStatusAsync(ct).ConfigureAwait(false),
37 ChatSlashIntercomHandlers.Ids.ServerStart => ServerStart(argsTail),
38 ChatSlashIntercomHandlers.Ids.ServerStop => ServerStop(),
39 ChatSlashIntercomHandlers.Ids.TeamMembers => await TeamMembersAsync(ct).ConfigureAwait(false),
40 ChatSlashIntercomHandlers.Ids.TeamInvite => await TeamInviteAsync(argsTail, ct).ConfigureAwait(false),
41 ChatSlashIntercomHandlers.Ids.TeamSeedProject => await TeamSeedProjectAsync(argsTail, ct).ConfigureAwait(false),
42 ChatSlashIntercomHandlers.Ids.AgentList => await AgentListAsync(ct).ConfigureAwait(false),
43 ChatSlashIntercomHandlers.Ids.AgentProvision => await AgentProvisionAsync(argsTail, ct).ConfigureAwait(false),
44 ChatSlashIntercomHandlers.Ids.AgentSelect => AgentSelect(argsTail),
45 _ => ChatSlashIntercomResult.Fail($"Неизвестная admin-команда: {handlerId}"),
46 };
47 }
48
49 public async Task<(bool Ok, string Message)> RefreshWorkspaceContextAsync(CancellationToken ct = default)
50 {
51 var settings = _getSettings();
52 var transport = settings.Intercom.Transport;
53 if (!transport.IsConfigured)
54 return (false, "Transport не настроен.");
55
56 _transport.OnWorkspaceChanged();
57
58 if (!await _transport.EnsureBearerForAdminAsync(transport, ct).ConfigureAwait(false))
59 return (false, "Нужен Connect или dev_team_token.");
60
61 var bearer = await _transport.ResolveBearerForAdminAsync(transport, ct).ConfigureAwait(false);
62 var resolved = await IntercomWorkspaceContextResolver.ResolveAsync(
63 transport,
64 _transport.GetWorkspaceRootForAdmin(),
65 bearer,
66 _transport.ApiClient,
67 ct).ConfigureAwait(false);
68
69 if (!resolved.Found)
70 return (false, $"team не найден (repo={resolved.RepoKey ?? ""}).");
71
72 if (string.IsNullOrWhiteSpace(transport.TeamId))
73 transport.TeamId = resolved.TeamId;
74
75 _saveSettings();
76 return (true, $"workspace → team {resolved.TeamId} ({resolved.Source}).");
77 }
78
79 private async Task<ChatSlashIntercomResult> ServerStatusAsync(CancellationToken ct)
80 {
81 var settings = _getSettings().Intercom.Transport;
82 var host = _serverHost.DescribeStatus();
83 var baseUrl = settings.ResolveBaseUrl();
84 if (string.IsNullOrWhiteSpace(baseUrl))
85 return ChatSlashIntercomResult.Ok($"{host}\nbase_url не задан.");
86
87 _transport.ApiClient.ConfigureBaseUrl(baseUrl);
88 var ping = await _transport.ApiClient.PingHealthAsync(ct).ConfigureAwait(false);
89 var health = ping ? "HTTP /health OK" : "HTTP /health недоступен";
90
91 if (!await _transport.EnsureBearerForAdminAsync(settings, ct).ConfigureAwait(false))
92 return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. Bearer: нет.");
93
94 var bearer = await _transport.ResolveBearerForAdminAsync(settings, ct).ConfigureAwait(false);
95 var teamId = settings.TeamId.Trim();
96 if (string.IsNullOrWhiteSpace(teamId))
97 return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. team_id не задан.");
98
99 var admin = await _transport.ApiClient.GetTeamAdminHealthAsync(teamId, bearer!, ct).ConfigureAwait(false);
100 var adminText = admin is null
101 ? "admin/health: нет доступа"
102 : $"admin/health: {admin.Status}, SSE={admin.SseSubscribers}";
103 return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. {adminText}. Transport: {_transport.ConnectionStatus}");
104 }
105
106 private ChatSlashIntercomResult ServerStart(string? argsTail)
107 {
108 var settings = _getSettings().Intercom.Transport;
109 var url = string.IsNullOrWhiteSpace(argsTail) ? settings.ResolveBaseUrl() : argsTail.Trim();
110 if (string.IsNullOrWhiteSpace(url))
111 url = _serverHost.DefaultBaseUrl;
112
113 var (ok, message) = _serverHost.Start(url, settings.ResolveLocalServerPath());
114 if (ok && string.IsNullOrWhiteSpace(settings.BaseUrl))
115 {
116 settings.BaseUrl = url;
117 settings.Enabled = true;
118 _saveSettings();
119 }
120
121 return ok ? ChatSlashIntercomResult.Ok(message) : ChatSlashIntercomResult.Fail(message);
122 }
123
124 private ChatSlashIntercomResult ServerStop()
125 {
126 var (ok, message) = _serverHost.Stop();
127 return ok ? ChatSlashIntercomResult.Ok(message) : ChatSlashIntercomResult.Fail(message);
128 }
129
130 private async Task<ChatSlashIntercomResult> TeamMembersAsync(CancellationToken ct)
131 {
132 var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false);
133 if (error is not null)
134 return ChatSlashIntercomResult.Fail(error);
135
136 var members = await _transport.ApiClient.ListTeamMembersAsync(teamId!, bearer!, ct).ConfigureAwait(false);
137 if (members.Count == 0)
138 return ChatSlashIntercomResult.Ok("Участников нет.");
139
140 var lines = members.Select(m =>
141 $"{m.MemberId} · {m.MemberKind}/{m.TeamRole} · {m.DisplayName}");
142 return ChatSlashIntercomResult.Ok(string.Join(Environment.NewLine, lines));
143 }
144
145 private async Task<ChatSlashIntercomResult> TeamInviteAsync(string? argsTail, CancellationToken ct)
146 {
147 var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false);
148 if (error is not null)
149 return ChatSlashIntercomResult.Fail(error);
150
151 var role = string.IsNullOrWhiteSpace(argsTail) ? "member" : argsTail.Trim();
152 var invite = await _transport.ApiClient.CreateInviteAsync(teamId!, role, bearer!, ct).ConfigureAwait(false);
153 if (invite is null)
154 return ChatSlashIntercomResult.Fail("Не удалось создать invite (нужна роль admin).");
155
156 return ChatSlashIntercomResult.Ok(
157 $"invite {invite.InviteId}: role={invite.TeamRole}, token={invite.Token}, expires={invite.ExpiresAtUtc}");
158 }
159
160 private async Task<ChatSlashIntercomResult> TeamSeedProjectAsync(string? argsTail, CancellationToken ct)
161 {
162 var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false);
163 if (error is not null)
164 return ChatSlashIntercomResult.Fail(error);
165
166 var repoRoot = _transport.GetWorkspaceRootForAdmin();
167 var repoUrl = IntercomWorkspaceGitRemoteResolver.TryGetNormalizedOrigin(repoRoot);
168 if (string.IsNullOrWhiteSpace(repoUrl))
169 return ChatSlashIntercomResult.Fail("Не удалось нормализовать origin репозитория.");
170
171 var projectId = string.IsNullOrWhiteSpace(argsTail)
172 ? SanitizeProjectId(teamId!)
173 : argsTail.Trim();
174
175 var created = await _transport.ApiClient.CreateProjectAsync(projectId, projectId, bearer!, ct)
176 .ConfigureAwait(false);
177 if (created is null)
178 return ChatSlashIntercomResult.Fail("POST /projects не удался.");
179
180 if (!await _transport.ApiClient.PutProjectReposAsync(projectId, [repoUrl], bearer!, ct).ConfigureAwait(false))
181 return ChatSlashIntercomResult.Fail("PUT project repos не удался.");
182
183 if (!await _transport.ApiClient.PutTeamProjectsAsync(teamId!, [projectId], bearer!, ct).ConfigureAwait(false))
184 return ChatSlashIntercomResult.Fail("PUT team projects не удался.");
185
186 _ = await RefreshWorkspaceContextAsync(ct).ConfigureAwait(false);
187 return ChatSlashIntercomResult.Ok($"project {projectId} ← {repoUrl}, team {teamId} привязан.");
188 }
189
190 private async Task<ChatSlashIntercomResult> AgentListAsync(CancellationToken ct)
191 {
192 var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false);
193 if (error is not null)
194 return ChatSlashIntercomResult.Fail(error);
195
196 var members = await _transport.ApiClient.ListTeamMembersAsync(teamId!, bearer!, ct).ConfigureAwait(false);
197 var agents = members.Where(m => string.Equals(m.MemberKind, "agent", StringComparison.Ordinal)).ToList();
198 if (agents.Count == 0)
199 return ChatSlashIntercomResult.Ok("Агентов нет. /intercom agent provision <имя>");
200
201 var selected = _getSettings().Intercom.Transport.SelectedAgentMemberId;
202 var lines = agents.Select(a =>
203 {
204 var mark = string.Equals(a.MemberId, selected, StringComparison.Ordinal) ? " *" : "";
205 return $"{a.MemberId}{mark} · {a.DisplayName}";
206 });
207 return ChatSlashIntercomResult.Ok(string.Join(Environment.NewLine, lines));
208 }
209
210 private async Task<ChatSlashIntercomResult> AgentProvisionAsync(string? argsTail, CancellationToken ct)
211 {
212 var name = argsTail?.Trim() ?? "";
213 if (string.IsNullOrWhiteSpace(name))
214 return ChatSlashIntercomResult.Fail("Укажи имя: /intercom agent provision <display name>");
215
216 var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false);
217 if (error is not null)
218 return ChatSlashIntercomResult.Fail(error);
219
220 var agent = await _transport.ApiClient.ProvisionAgentAsync(teamId!, name, null, bearer!, ct)
221 .ConfigureAwait(false);
222 if (agent is null)
223 return ChatSlashIntercomResult.Fail("Provision не удался (нужна роль admin).");
224
225 var secrets = IntercomAgentSecretsStorage.Load();
226 secrets.CredentialsByTeamAgent[IntercomAgentSecrets.MakeKey(teamId!, agent.MemberId)] =
227 agent.CredentialToken;
228 IntercomAgentSecretsStorage.Save(secrets);
229
230 var transport = _getSettings().Intercom.Transport;
231 transport.SelectedAgentMemberId = agent.MemberId;
232 transport.SelectedAgentDisplayName = agent.DisplayName;
233 _saveSettings();
234
235 return ChatSlashIntercomResult.Ok(
236 $"Агент {agent.DisplayName} ({agent.MemberId}). Credential сохранён в LocalAppData (не в git).");
237 }
238
239 private ChatSlashIntercomResult AgentSelect(string? argsTail)
240 {
241 var memberId = argsTail?.Trim() ?? "";
242 if (string.IsNullOrWhiteSpace(memberId))
243 return ChatSlashIntercomResult.Fail("Укажи member_id: /intercom agent select <id>");
244
245 var transport = _getSettings().Intercom.Transport;
246 transport.SelectedAgentMemberId = memberId;
247 transport.SelectedAgentDisplayName = "";
248 _saveSettings();
249 return ChatSlashIntercomResult.Ok($"Выбран агент {memberId} для transport fan-out.");
250 }
251
252 private async Task<(string? Bearer, string? TeamId, string? Error)> RequireTeamBearerAsync(CancellationToken ct)
253 {
254 var settings = _getSettings().Intercom.Transport;
255 if (!settings.IsConfigured)
256 return (null, null, "Transport не настроен (base_url).");
257
258 _transport.ApiClient.ConfigureBaseUrl(settings.ResolveBaseUrl());
259 if (!await _transport.EnsureBearerForAdminAsync(settings, ct).ConfigureAwait(false))
260 return (null, null, "Нужен Connect или dev_team_token.");
261
262 var teamId = settings.TeamId.Trim();
263 if (string.IsNullOrWhiteSpace(teamId))
264 return (null, null, "team_id не задан.");
265
266 var bearer = await _transport.ResolveBearerForAdminAsync(settings, ct).ConfigureAwait(false);
267 return string.IsNullOrWhiteSpace(bearer)
268 ? (null, null, "Bearer недоступен.")
269 : (bearer, teamId, null);
270 }
271
272 private static string SanitizeProjectId(string teamId) =>
273 new string(teamId.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray());
274}
275
View only · write via MCP/CIDE