Workflow-status: a new WorkflowProgressTracker singleton taps the
suppressed dynamic-workflow subagent events (those carrying
parent_tool_use_id) into per-session telemetry without altering the
existing guard; the snapshot is serialized into the new chains.workflow_info
column on FinalizeChain (ChainId-matched so a panel member of the same
session cannot clear it) and exposed via GET /api/sessions/{id}/workflow-status
(live -> persisted -> none). The header menu gains a 'Workflow-Status' entry
with a modal that live-polls every 2s while running and self-stops on close.
Panel on-demand: Fusion/MoA is now a transient per-request multiModelMode
flag (like plan/ultracode) instead of a persisted session column. Session
settings keep only the panel members + moa_layers; the mode <select> was
removed and the composer dropdown drives the mode per request.
Docs: ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md updated for both
features plus the already-shipped z.ai/GLM provider, GLM 5.2+ effort
passthrough, web-search-prime MCP and the ultracode toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
836 lines
35 KiB
C#
836 lines
35 KiB
C#
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.MapGet("/api/sessions/{sessionId}/workflow-status", HandleWorkflowStatus);
|
||
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 moaLayers = body.TryGetProperty("moaLayers", out var mol) && mol.ValueKind == JsonValueKind.Number ? mol.GetInt32() : 2;
|
||
|
||
var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId, multiModelMode, panelAgentIds, moaLayers);
|
||
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 });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Status eines dynamischen Workflows (ultracode) für die Session: LIVE aus dem
|
||
/// WorkflowProgressTracker, solange ein Lauf aktiv ist – sonst die zuletzt persistierte
|
||
/// Telemetrie (chains.workflow_info) der jüngsten Chain. Liefert Subagent-Anzahl,
|
||
/// Tool-Aufrufe (+ Aufschlüsselung) und aggregierte Subagent-Tokens.
|
||
/// </summary>
|
||
private static async Task HandleWorkflowStatus(HttpContext ctx, DatabaseService db, StreamingTracker streamingTracker, WorkflowProgressTracker workflowTracker)
|
||
{
|
||
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 streaming = streamingTracker.IsStreaming(sessionId);
|
||
|
||
// 1) Live: ein Workflow läuft gerade (Tracker hat einen Eintrag).
|
||
var live = workflowTracker.GetSnapshot(sessionId);
|
||
if (live != null)
|
||
{
|
||
await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = "running", chainId = live.ChainId, workflow = live });
|
||
return;
|
||
}
|
||
|
||
// 2) Sonst: zuletzt persistierte Workflow-Telemetrie aus den aktiven Chains der Session.
|
||
var chains = await db.GetActiveChains(sessionId); // ORDER BY id DESC
|
||
var last = chains.FirstOrDefault(c => !string.IsNullOrEmpty(c.WorkflowInfo));
|
||
if (last != null)
|
||
{
|
||
WorkflowProgress wp = null;
|
||
try { wp = JsonSerializer.Deserialize<WorkflowProgress>(last.WorkflowInfo); } catch { }
|
||
await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = "completed", chainId = last.ChainId, workflow = wp });
|
||
return;
|
||
}
|
||
|
||
// 3) Für diese Session lief nie ein Workflow.
|
||
await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = "none", workflow = (object)null });
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|