Forge
csharpdeeb25a2
1using System.Diagnostics;
2using CascadeIDE.Models;
3
4namespace CascadeIDE.Features.Intercom.Admin;
5
6/// <summary>Локальный supervisor <c>intercom-service</c> (ADR 0147 фаза 2).</summary>
7public sealed class IntercomServerHostService : IDisposable
8{
9 private Process? _process;
10 private string _lastStderr = "";
11 private string _lastStdout = "";
12 private string _lastLaunchSource = "";
13
14 public bool IsRunning => _process is { HasExited: false };
15
16 public string DefaultBaseUrl => IntercomTransportSettings.DefaultBaseUrl;
17
18 public string LastLaunchSource => _lastLaunchSource;
19
20 public (bool Ok, string Message) Start(string? baseUrl = null, string? configuredExecutablePath = null)
21 {
22 if (IsRunning)
23 return (true, $"Уже запущен (PID {_process!.Id}, {_lastLaunchSource}).");
24
25 var url = string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl.Trim().TrimEnd('/');
26 var launch = TryResolveLaunch(configuredExecutablePath);
27 if (launch is null)
28 return (false, "Не найден IntercomService (укажи [intercom.transport].local_server_path или выполни scripts/intercom/publish-intercom-service.ps1).");
29
30 var psi = new ProcessStartInfo
31 {
32 FileName = launch.FileName,
33 Arguments = string.IsNullOrWhiteSpace(launch.Arguments)
34 ? $"--urls {url}"
35 : $"{launch.Arguments} --urls {url}",
36 UseShellExecute = false,
37 // Child must not inherit IDE stdout: in --mcp-stdio it carries MCP JSON-RPC.
38 RedirectStandardOutput = true,
39 RedirectStandardError = true,
40 CreateNoWindow = true,
41 WorkingDirectory = launch.WorkingDirectory,
42 };
43 psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
44
45 try
46 {
47 _process = Process.Start(psi);
48 if (_process is null)
49 return (false, "Process.Start вернул null.");
50
51 _lastLaunchSource = launch.Source;
52 _ = Task.Run(async () =>
53 {
54 try
55 {
56 _lastStdout = await _process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
57 }
58 catch
59 {
60 // ignore
61 }
62 });
63 _ = Task.Run(async () =>
64 {
65 try
66 {
67 _lastStderr = await _process.StandardError.ReadToEndAsync().ConfigureAwait(false);
68 }
69 catch
70 {
71 // ignore
72 }
73 });
74
75 return (true, $"intercom-service ({launch.Source}, {url}, PID {_process.Id}).");
76 }
77 catch (Exception ex)
78 {
79 return (false, ex.Message);
80 }
81 }
82
83 public (bool Ok, string Message) Stop()
84 {
85 if (_process is null)
86 return (true, "Сервер не запущен из CIDE.");
87
88 try
89 {
90 if (!_process.HasExited)
91 {
92 _process.Kill(entireProcessTree: true);
93 _process.WaitForExit(5000);
94 }
95
96 var tail = FormatProcessLogTail(_lastStdout, _lastStderr);
97 var msg = string.IsNullOrWhiteSpace(tail)
98 ? "intercom-service остановлен."
99 : $"intercom-service остановлен.\n{tail}";
100 _process.Dispose();
101 _process = null;
102 _lastLaunchSource = "";
103 _lastStdout = "";
104 _lastStderr = "";
105 return (true, msg);
106 }
107 catch (Exception ex)
108 {
109 return (false, ex.Message);
110 }
111 }
112
113 public string DescribeStatus()
114 {
115 if (!IsRunning)
116 return "intercom-service: не запущен из CIDE.";
117
118 return $"intercom-service: PID {_process!.Id}, {_lastLaunchSource}, base {DefaultBaseUrl}.";
119 }
120
121 public static IntercomServerLaunchPlan? TryResolveLaunch(string? configuredExecutablePath)
122 {
123 var effective = string.IsNullOrWhiteSpace(configuredExecutablePath)
124 ? IntercomTransportSettings.DefaultLocalServerRelativePath
125 : configuredExecutablePath.Trim();
126
127 if (TryNormalizeExistingFile(effective, out var configured))
128 return configured;
129
130 foreach (var candidate in EnumerateFallbackExecutableCandidates())
131 {
132 if (TryNormalizeExistingFile(candidate, out var plan))
133 return plan;
134 }
135
136 var project = TryResolveServiceProjectPath();
137 if (project is null)
138 return null;
139
140 return new IntercomServerLaunchPlan(
141 FileName: "dotnet",
142 Arguments: $"run --project \"{project}\"",
143 WorkingDirectory: Path.GetDirectoryName(project)!,
144 Source: "dotnet run (dev)");
145 }
146
147 public static IEnumerable<string> EnumerateFallbackExecutableCandidates()
148 {
149 foreach (var root in EnumerateSearchRoots())
150 {
151 yield return Path.Combine(root, "artifacts", "intercom-service", "IntercomService.exe");
152 yield return Path.Combine(root, "host", "intercom-service", "publish", "IntercomService.exe");
153 yield return Path.Combine(
154 root,
155 "host",
156 "intercom-service",
157 "src",
158 "IntercomService",
159 "bin",
160 "Release",
161 "net10.0",
162 "win-x64",
163 "publish",
164 "IntercomService.exe");
165 yield return Path.Combine(
166 root,
167 "host",
168 "intercom-service",
169 "src",
170 "IntercomService",
171 "bin",
172 "Debug",
173 "net10.0",
174 "win-x64",
175 "publish",
176 "IntercomService.exe");
177 }
178 }
179
180 private static bool TryNormalizeExistingFile(string? path, out IntercomServerLaunchPlan plan)
181 {
182 plan = default!;
183 if (string.IsNullOrWhiteSpace(path))
184 return false;
185
186 foreach (var full in ExpandPathCandidates(path.Trim().Trim('"')))
187 {
188 if (!File.Exists(full))
189 continue;
190
191 plan = new IntercomServerLaunchPlan(
192 FileName: full,
193 Arguments: "",
194 WorkingDirectory: Path.GetDirectoryName(full) ?? AppContext.BaseDirectory,
195 Source: "settings.local_server_path");
196 return true;
197 }
198
199 return false;
200 }
201
202 private static IEnumerable<string> ExpandPathCandidates(string path)
203 {
204 if (Path.IsPathRooted(path))
205 {
206 yield return Path.GetFullPath(path);
207 yield break;
208 }
209
210 yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
211 foreach (var root in EnumerateSearchRoots())
212 yield return Path.GetFullPath(Path.Combine(root, path));
213 }
214
215 public static string? TryResolveServiceProjectPath()
216 {
217 foreach (var root in EnumerateSearchRoots())
218 {
219 var candidate = Path.Combine(
220 root,
221 "host",
222 "intercom-service",
223 "src",
224 "IntercomService",
225 "IntercomService.csproj");
226 if (File.Exists(candidate))
227 return candidate;
228 }
229
230 return null;
231 }
232
233 private static IEnumerable<string> EnumerateSearchRoots()
234 {
235 var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
236 foreach (var start in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() })
237 {
238 var dir = new DirectoryInfo(start);
239 for (var i = 0; i < 12 && dir is not null; i++, dir = dir.Parent)
240 {
241 var full = dir.FullName;
242 if (seen.Add(full))
243 yield return full;
244 }
245 }
246 }
247
248 private static string FormatProcessLogTail(string stdout, string stderr)
249 {
250 var parts = new List<string>(2);
251 if (!string.IsNullOrWhiteSpace(stdout))
252 parts.Add(stdout.Trim());
253 if (!string.IsNullOrWhiteSpace(stderr))
254 parts.Add(stderr.Trim());
255 return string.Join(Environment.NewLine, parts);
256 }
257
258 public void Dispose() => Stop();
259}
260
261public sealed record IntercomServerLaunchPlan(
262 string FileName,
263 string Arguments,
264 string WorkingDirectory,
265 string Source);
266
View only · write via MCP/CIDE