Files
gott/Endpoints/SessionEndpoint.cs
T
MelekiandClaude Opus 4.8 268f11fa6d Add multi-model panel with Fusion mode
Panel agents answer the user prompt in parallel (read-only, non-streaming);
their responses are merged into the worker's last user message as a
<panel_responses> block, and the session agent synthesizes one final
streamed answer. The RunPanel engine is built layer-ready for future MoA.

- New session fields: multi_model_mode (off/fusion), panel_agent_ids (CSV)
  with idempotent column migration
- New partial Services/AnthropicService.Panel.cs holding the RunPanel engine
- Provider-agnostic members: Anthropic RunNonStreamingToolLoop / OpenAI
  CompleteText / CLI plan-mode; all forced read-only, persisted with Tag=panel
- SSE events: panel_start, panel_member_start, panel_member_done, panel_done
- Frontend: multi-agent picker, live panel bubbles, history rendering
- Graceful degradation: panel errors fall back to worker-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:55:06 +02:00

786 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SessionEndpoint
{
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/sessions", HandleList);
app.MapGet("/api/sessions/{sessionId}", HandleGet);
app.MapPost("/api/sessions", HandleCreate);
app.MapPut("/api/sessions/{sessionId}", HandleUpdate);
app.MapDelete("/api/sessions/{sessionId}", HandleDelete);
app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt);
app.MapGet("/api/sessions/{sessionId}/system-prompt-blocks", HandleSystemPromptBlocks);
app.MapGet("/api/sessions/{sessionId}/payload-preview", HandlePayloadPreview);
app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming);
app.MapPost("/api/sessions/{sessionId}/exec", HandleExec);
app.MapGet("/api/sessions/{sessionId}/git-status", HandleGitStatus);
}
private static async Task HandleList(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var user = await db.GetUser(userId);
var sessionList = await db.GetSessionsWithStats(userId);
// Git-Status parallel für alle Sessions mit Workspace laden
var gitTasks = new Dictionary<int, Task<GitStatusSummary>>();
for (int i = 0; i < sessionList.Count; i++)
{
var resolvedWs = WorkspaceHelper.ResolveWorkspace(sessionList[i].Session, user);
if (!string.IsNullOrEmpty(resolvedWs))
gitTasks[i] = GetGitStatusSummaryAsync(resolvedWs);
}
if (gitTasks.Count > 0)
await Task.WhenAll(gitTasks.Values);
// Ergebnisse zusammenführen
var results = new List<object>();
for (int i = 0; i < sessionList.Count; i++)
{
var sw = sessionList[i];
var s = sw.Session;
object gitStatus = null;
if (gitTasks.TryGetValue(i, out var task) && task.IsCompletedSuccessfully && task.Result != null)
{
var gs = task.Result;
if (gs.Add > 0 || gs.Edit > 0 || gs.Delete > 0 || gs.Outgoing > 0)
gitStatus = new { add = gs.Add, edit = gs.Edit, delete = gs.Delete, outgoing = gs.Outgoing };
}
results.Add(new
{
s.SessionId,
s.Name,
s.Description,
Image = s.ImageSmall ?? s.Image,
s.AgentId,
s.Workspace,
s.Context,
s.CustomCommands,
s.AvatarScale,
s.AvatarOffsetX,
s.AvatarOffsetY,
s.ReviewAgentId,
s.MaxReviewIterations,
s.FinalizerAgentId,
s.PreparerAgentId,
s.UnityMcpUrl,
s.UnityMcpToken,
s.IsDeleted,
s.CreatedAt,
s.UpdatedAt,
sw.MessageCount,
sw.ArchiveMessageCount,
sw.TotalMessageCount,
sw.TelegramConfigured,
sw.TelegramLinked,
GitStatus = gitStatus
});
}
await ctx.Response.WriteAsJsonAsync(results);
}
private static async Task HandleGet(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var name = body.TryGetProperty("name", out var n) ? n.GetString() : "Neue Session";
var description = body.TryGetProperty("description", out var d) ? d.GetString() : null;
var imageRaw = body.TryGetProperty("image", out var img) ? img.GetString() : null;
var image = imageService.ResizeDataUrl(imageRaw);
var imageSmall = imageService.ResizeDataUrlSmall(imageRaw);
var agentId = body.TryGetProperty("agentId", out var a) ? a.GetString() : null;
var workspaceRaw = body.TryGetProperty("workspace", out var w) ? w.GetString() : null;
// Relativen Workspace validieren (Path-Traversal verhindern)
var workspace = !string.IsNullOrWhiteSpace(workspaceRaw) && !System.IO.Path.IsPathRooted(workspaceRaw)
? WorkspaceHelper.ValidateRelativeWorkspace(workspaceRaw)
: workspaceRaw;
if (!string.IsNullOrWhiteSpace(workspaceRaw) && workspace == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
var avatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0;
var avatarOffsetX = body.TryGetProperty("avatarOffsetX", out var aox) ? aox.GetDouble() : 0;
var avatarOffsetY = body.TryGetProperty("avatarOffsetY", out var aoy) ? aoy.GetDouble() : 0;
var reviewAgentId = body.TryGetProperty("reviewAgentId", out var ra) ? ra.GetString() : null;
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null;
var preparerAgentId = body.TryGetProperty("preparerAgentId", out var pra) ? pra.GetString() : null;
var multiModelMode = body.TryGetProperty("multiModelMode", out var mmm) ? mmm.GetString() : null;
var panelAgentIds = body.TryGetProperty("panelAgentIds", out var pai) && pai.ValueKind != JsonValueKind.Null ? pai.GetString() : null;
var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId, multiModelMode, panelAgentIds);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var existing = await db.GetSession(sessionId);
if (existing == null || existing.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
// Workspace-Pfad validieren (Path-Traversal verhindern)
if (body.TryGetProperty("workspace", out var wsEl))
{
var wsRaw = wsEl.GetString();
if (!string.IsNullOrWhiteSpace(wsRaw) && !System.IO.Path.IsPathRooted(wsRaw))
{
var validated = WorkspaceHelper.ValidateRelativeWorkspace(wsRaw);
if (validated == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
}
}
// Session einem anderen Benutzer zuweisen. Jeder Besitzer darf seine EIGENE Session
// übergeben – die Ownership-Prüfung oben (existing.UserId == userId) stellt bereits sicher,
// dass der Aufrufer der Besitzer ist. Der Ziel-User muss existieren.
if (body.TryGetProperty("userId", out var newUid)
&& newUid.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(newUid.GetString()))
{
var targetUser = await db.GetUser(newUid.GetString());
if (targetUser == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Zielbenutzer nicht gefunden" });
return;
}
}
await db.UpdateSession(sessionId, body, imageService, allowUserReassign: true);
var session = await db.GetSession(sessionId);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleDelete(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return; }
await db.DeleteSession(sessionId, userId);
ctx.Response.StatusCode = 204;
}
/// <summary>
/// Löst Session, Agent, Workspace und Prompt-Abhängigkeiten auf.
/// Gibt null zurück und setzt StatusCode bei Fehler.
/// </summary>
private static async Task<(SessionRecord session, AgentRecord agent, IEnumerable<MessageRecord> marked,
string memDir, IEnumerable<string> skills, UserRecord user, string workspace, bool planMode)?>
ResolvePromptContext(HttpContext ctx, DatabaseService db, MemoryService memoryService)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return null; }
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId) { ctx.Response.StatusCode = 404; return null; }
if (string.IsNullOrEmpty(session.AgentId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" });
return null;
}
var agent = await db.GetAgent(session.AgentId);
if (agent == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Agent nicht gefunden" });
return null;
}
var marked = await db.GetMarkedMessages(sessionId);
var memDir = memoryService.GetAgentMemoryDir(agent.AgentId);
var skills = await db.GetAgentSkills(agent.AgentId);
var user = await db.GetUser(userId);
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && skills.Any())
workspace = WorkspaceHelper.TempDir;
var planMode = ctx.Request.Query.ContainsKey("planMode") && ctx.Request.Query["planMode"] == "true";
return (session, agent, marked, memDir, skills, user, workspace, planMode);
}
private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
var systemPrompt = SystemPromptBuilder.Build(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(systemPrompt);
}
private static async Task HandleSystemPromptBlocks(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
var blocks = SystemPromptBuilder.BuildBlocks(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
await ctx.Response.WriteAsJsonAsync(blocks.Select(b => new { b.Icon, b.Label, b.Content }));
}
private static async Task HandlePayloadPreview(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService, ChatService chatService, ToolService toolService, ServerSettingsService settings)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
// System-Prompt
var promptBlocks = SystemPromptBuilder.BuildBlocks(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content));
var systemPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(systemPromptText))
systemPrompt.Add(new { type = "text", text = systemPromptText });
// Model
var modelId = ChatService.ResolveModelId(agent.Model);
// Messages aus der DB (exakt wie beim Chat) – includeThinking je nach Provider
var previewProvider = OpenAiService.IsOpenAiProvider(modelId);
var messages = await chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: promptBlocks, includeThinking: previewProvider);
// Tools (exakt wie AddToolsToPayload es machen würde)
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
var includeGitHubSearch = agent.UseGitHubSearch && toolService.HasGitHubToken;
var includeSerperSearch = agent.UseSerperSearch && toolService.HasSerperKey;
var hasTools = hasWorkspace || includeMemory || includeGitHubSearch || includeSerperSearch;
var tools = new List<object>();
if (hasTools)
{
var allTools = toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = agent.AllowUserChoice,
IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch
});
if (planMode)
allTools = toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
}
if (!string.IsNullOrEmpty(agent.WebSearchVersion))
tools.Add(new { type = agent.WebSearchVersion, name = "web_search", max_uses = 5 });
var toolChoice = hasTools || !string.IsNullOrEmpty(agent.WebSearchVersion) ? "any" : null;
var effectiveChoice = toolChoice == "any" ? "any" : "auto";
// OpenAI-kompatible Provider: Payload im tatsächlichen API-Format anzeigen
if (previewProvider)
{
var (provider, _) = OpenAiService.ParseModelString(modelId);
var maxTokens = provider == "minimax" ? 32768 : 16384;
// Messages konvertieren (Anthropic → OpenAI-Format)
var openAiMessages = OpenAiService.BuildApiMessages(systemPromptText, messages);
// Tools konvertieren (input_schema → function/parameters)
var openAiTools = tools.Count > 0
? OpenAiService.ConvertTo_openAiToolDefs(tools.ToArray())
: new List<object>();
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = maxTokens,
["messages"] = openAiMessages,
["stream"] = true,
["stream_options"] = new { include_usage = true }
};
if (openAiTools.Count > 0)
{
payload["tools"] = openAiTools;
payload["tool_choice"] = "auto";
}
if (provider == "minimax")
{
payload["temperature"] = 1.0;
payload["top_p"] = 0.95;
payload["parallel_tool_calls"] = false;
}
if (provider == "nvidia")
{
var nvidiaModelId = modelId.Contains(":") ? modelId.Split(':')[1] : modelId;
if (nvidiaModelId.Contains("kimi-k2", StringComparison.OrdinalIgnoreCase)
|| nvidiaModelId.Contains("qwq", StringComparison.OrdinalIgnoreCase)
|| nvidiaModelId.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase))
payload["chat_template_kwargs"] = new { thinking = true };
}
ctx.Response.ContentType = "application/json; charset=utf-8";
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
return;
}
// Anthropic-Format Payload
var anthropicPayload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = 16384,
["messages"] = messages,
["stream"] = true
};
if (systemPrompt.Count > 0)
anthropicPayload["system"] = systemPrompt;
if (tools.Count > 0)
{
anthropicPayload["tools"] = tools;
anthropicPayload["tool_choice"] = new { type = effectiveChoice };
}
ctx.Response.ContentType = "application/json; charset=utf-8";
await ctx.Response.WriteAsync(JsonSerializer.Serialize(anthropicPayload, new JsonSerializerOptions { WriteIndented = true }));
}
private static async Task HandleStreaming(HttpContext ctx, DatabaseService db, StreamingTracker streamingTracker)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var isStreaming = streamingTracker.IsStreaming(sessionId);
var chainId = streamingTracker.GetChainId(sessionId);
await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId });
}
private static async Task HandleExec(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var command = body.TryGetProperty("command", out var c) ? c.GetString() : null;
var mode = body.TryGetProperty("mode", out var m) ? m.GetString() ?? "background" : "background";
if (string.IsNullOrWhiteSpace(command))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Befehl angegeben" });
return;
}
var user = await db.GetUser(userId);
var resolvedWs = WorkspaceHelper.ResolveWorkspace(session, user);
var workingDir = !string.IsNullOrWhiteSpace(resolvedWs) ? resolvedWs : WorkspaceHelper.TempDir;
if (mode == "foreground")
{
// Synchron ausführen, auf Ergebnis warten
try
{
string scriptPath = null;
var psi = new ProcessStartInfo
{
FileName = WorkspaceHelper.ShellExecutable,
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (WorkspaceHelper.IsWindows)
{
scriptPath = Path.Combine(Path.GetTempPath(), $"gott_exec_{Guid.NewGuid():N}.cmd");
await System.IO.File.WriteAllTextAsync(scriptPath, $"@echo off\r\n{command}\r\n");
psi.Arguments = $"/c \"{scriptPath}\"";
}
else
{
psi.RedirectStandardInput = true;
}
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
{
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" });
return;
}
if (!WorkspaceHelper.IsWindows)
{
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
}
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await proc.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true);
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 });
return;
}
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
var stdout = await stdoutTask;
var stderr = await stderrTask;
var output = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(stdout)) output.Append(stdout);
if (!string.IsNullOrEmpty(stderr))
{
if (output.Length > 0) output.Append("\n--- stderr ---\n");
output.Append(stderr);
}
await ctx.Response.WriteAsJsonAsync(new { status = "completed", output = output.ToString(), exitCode = proc.ExitCode });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
else
{
// Fire-and-forget
try
{
ProcessStartInfo psi;
if (WorkspaceHelper.IsWindows)
{
var scriptPath = Path.Combine(WorkspaceHelper.TempDir, $"gott-exec-{Guid.NewGuid():N}.bat");
await System.IO.File.WriteAllTextAsync(scriptPath, $"@echo off\r\ncd /d \"{workingDir}\"\r\n{command}\r\ndel \"%~f0\"\r\n");
psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{scriptPath}\"",
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
var scriptPath = Path.Combine(WorkspaceHelper.TempDir, $"gott-exec-{Guid.NewGuid():N}.sh");
await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n");
psi = new ProcessStartInfo
{
FileName = "systemd-run",
Arguments = $"--scope --quiet bash {scriptPath}",
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
var process = Process.Start(psi);
if (process != null)
{
process.WaitForExit(500);
}
ctx.Response.StatusCode = 202;
await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
}
private static async Task HandleGitStatus(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var user = await db.GetUser(userId);
var workingDir = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workingDir))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" });
return;
}
var result = await GetGitStatusDetailedAsync(workingDir);
if (result == null)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Git-Status konnte nicht ermittelt werden" });
return;
}
await ctx.Response.WriteAsJsonAsync(result);
}
/// <summary>
/// Ermittelt eine Git-Status-Zusammenfassung (nur Counts) für die Dashboard-Ansicht.
/// Gibt null zurück bei Fehlern (kein Git-Repo, kein git installiert etc.).
/// </summary>
private static async Task<GitStatusSummary> GetGitStatusSummaryAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
int addCount = 0, editCount = 0, deleteCount = 0;
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
if (x == '?' && y == '?') addCount++;
else if (x == 'D' || y == 'D') deleteCount++;
else if (x == 'A') addCount++;
else editCount++;
}
// Outgoing commits
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var c))
outgoing = c;
}
}
catch { }
return new GitStatusSummary { Add = addCount, Edit = editCount, Delete = deleteCount, Outgoing = outgoing };
}
catch
{
return null;
}
}
/// <summary>
/// Ermittelt den detaillierten Git-Status (mit Dateinamen) für den Einzel-Endpoint.
/// </summary>
private static async Task<object> GetGitStatusDetailedAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
var add = new List<string>();
var edit = new List<string>();
var delete = new List<string>();
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
var file = line.Substring(3);
var arrowIdx = file.IndexOf(" -> ", StringComparison.Ordinal);
if (arrowIdx >= 0)
file = file.Substring(arrowIdx + 4);
if (x == '?' && y == '?') add.Add(file);
else if (x == 'D' || y == 'D') delete.Add(file);
else if (x == 'A') add.Add(file);
else edit.Add(file);
}
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var count))
outgoing = count;
}
}
catch { }
return new { add, edit, delete, outgoing };
}
catch
{
return null;
}
}
}