Cowork와 Code를 동시에 실행할 때 메인 루프 번호와 라이브 진행 힌트가 서로 섞이던 문제를 수정했다. ChatWindow가 전역 단일 진행 상태를 공유하던 구조를 탭별 현재 run 상태, 진행 스텝, 라이브 힌트, 대기 UI 이벤트로 분리해 현재 탭 기준으로만 렌더링하도록 정리했다. AppStateService에 탭별 최신 run 상태 추적을 추가하고 ConversationList, TaskSummary, Timeline, V2 라이브 카드가 활성 탭의 run 메타를 읽도록 변경했다. AppStateServiceTests에 탭별 run iteration 분리 회귀 테스트를 추가했고 README와 DEVELOPMENT 문서에도 2026-04-15 22:25 (KST) 기준 이력을 반영했다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tab_loop_isolation\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AppStateServiceTests" -p:OutputPath=bin\\verify_tab_loop_isolation_tests\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation_tests\\ (통과 45)
1082 lines
42 KiB
C#
1082 lines
42 KiB
C#
using AxCopilot.Services.Agent;
|
|
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// 앱 전역에서 공유되는 상태 요약 저장소.
|
|
/// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아
|
|
/// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다.
|
|
/// </summary>
|
|
public sealed class AppStateService : IAppStateService
|
|
{
|
|
public sealed class SkillCatalogState
|
|
{
|
|
public bool Enabled { get; set; }
|
|
public string FolderPath { get; set; } = "";
|
|
public int LoadedCount { get; set; }
|
|
public int AvailableCount { get; set; }
|
|
public int UnavailableCount { get; set; }
|
|
}
|
|
|
|
public sealed class McpCatalogState
|
|
{
|
|
public int ConfiguredServerCount { get; set; }
|
|
public int EnabledServerCount { get; set; }
|
|
public int ConnectedServerCount { get; set; }
|
|
public int RegisteredToolCount { get; set; }
|
|
}
|
|
|
|
public sealed class PermissionPolicyState
|
|
{
|
|
public string FilePermission { get; set; } = "Deny";
|
|
public string AgentDecisionLevel { get; set; } = "detailed";
|
|
public int ToolOverrideCount { get; set; }
|
|
public List<KeyValuePair<string, string>> ToolOverrides { get; set; } = new();
|
|
}
|
|
|
|
public sealed class AgentCatalogState
|
|
{
|
|
public int RegisteredToolCount { get; set; }
|
|
public int McpToolCount { get; set; }
|
|
public int SkillToolCount { get; set; }
|
|
}
|
|
|
|
public sealed class PermissionEventState
|
|
{
|
|
public string RunId { get; init; } = "";
|
|
public string ToolName { get; init; } = "";
|
|
public string Status { get; init; } = "requested";
|
|
public string Summary { get; init; } = "";
|
|
public DateTime Timestamp { get; init; } = DateTime.Now;
|
|
}
|
|
|
|
public sealed class HookEventState
|
|
{
|
|
public string RunId { get; init; } = "";
|
|
public string ToolName { get; init; } = "";
|
|
public bool Success { get; init; }
|
|
public string Summary { get; init; } = "";
|
|
public DateTime Timestamp { get; init; } = DateTime.Now;
|
|
}
|
|
|
|
public sealed class BackgroundJobSummaryState
|
|
{
|
|
public int ActiveCount { get; init; }
|
|
public BackgroundJobService.BackgroundJobState? LatestRecent { get; init; }
|
|
}
|
|
|
|
public sealed class PermissionSummaryState
|
|
{
|
|
public string EffectiveMode { get; init; } = "Deny";
|
|
public string DefaultMode { get; init; } = "Deny";
|
|
public int OverrideCount { get; init; }
|
|
public string RiskLevel { get; init; } = "normal";
|
|
public string Description { get; init; } = "";
|
|
public IReadOnlyList<KeyValuePair<string, string>> TopOverrides { get; init; } = Array.Empty<KeyValuePair<string, string>>();
|
|
}
|
|
|
|
public sealed class AgentRunState
|
|
{
|
|
public string RunId { get; set; } = "";
|
|
public string Status { get; set; } = "idle";
|
|
public string Summary { get; set; } = "";
|
|
public int LastIteration { get; set; }
|
|
public DateTime StartedAt { get; set; } = DateTime.Now;
|
|
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
|
}
|
|
|
|
public sealed class TaskSummaryState
|
|
{
|
|
public int ActiveCount { get; init; }
|
|
public int PendingPermissionCount { get; init; }
|
|
public TaskRunStore.TaskRun? LatestRecentTask { get; init; }
|
|
public AgentRunState? LatestFailedRun { get; init; }
|
|
public bool HasActiveTasks => ActiveCount > 0;
|
|
}
|
|
|
|
public sealed class ConversationRunSummaryState
|
|
{
|
|
public int AgentRunCount { get; init; }
|
|
public int FailedAgentRunCount { get; init; }
|
|
public string LastAgentRunSummary { get; init; } = "";
|
|
public DateTime? LastFailedAt { get; init; }
|
|
public DateTime? LastCompletedAt { get; init; }
|
|
}
|
|
|
|
public sealed class RunDetailSummaryState
|
|
{
|
|
public string RunId { get; init; } = "";
|
|
public List<ChatExecutionEvent> Events { get; init; } = new();
|
|
public List<string> FilePaths { get; init; } = new();
|
|
}
|
|
|
|
public sealed class RunPlanHistoryState
|
|
{
|
|
public string? OriginalSummary { get; init; }
|
|
public List<string> OriginalSteps { get; init; } = new();
|
|
public string? RevisedSummary { get; init; }
|
|
public List<string> RevisedSteps { get; init; } = new();
|
|
public string? FinalApprovedSummary { get; init; }
|
|
public List<string> FinalApprovedSteps { get; init; } = new();
|
|
public bool HasAny =>
|
|
!string.IsNullOrWhiteSpace(OriginalSummary)
|
|
|| !string.IsNullOrWhiteSpace(RevisedSummary)
|
|
|| !string.IsNullOrWhiteSpace(FinalApprovedSummary)
|
|
|| OriginalSteps.Count > 0
|
|
|| RevisedSteps.Count > 0
|
|
|| FinalApprovedSteps.Count > 0;
|
|
}
|
|
|
|
public sealed class RunDisplayState
|
|
{
|
|
public string HeaderText { get; init; } = "";
|
|
public string MetaText { get; init; } = "";
|
|
public string SummaryText { get; init; } = "";
|
|
}
|
|
|
|
public sealed class DraftQueueSummaryState
|
|
{
|
|
public int TotalCount { get; init; }
|
|
public int QueuedCount { get; init; }
|
|
public int RunningCount { get; init; }
|
|
public int BlockedCount { get; init; }
|
|
public int FailedCount { get; init; }
|
|
public int CompletedCount { get; init; }
|
|
public DraftQueueItem? NextItem { get; init; }
|
|
public DateTime? NextReadyAt { get; init; }
|
|
}
|
|
|
|
public sealed class OperationalStatusState
|
|
{
|
|
public bool ShowRuntimeBadge { get; init; }
|
|
public string RuntimeLabel { get; init; } = "";
|
|
public bool ShowLastCompleted { get; init; }
|
|
public string LastCompletedText { get; init; } = "";
|
|
public string StripKind { get; init; } = "none";
|
|
public string StripText { get; init; } = "";
|
|
}
|
|
|
|
public sealed class OperationalStatusPresentationState
|
|
{
|
|
public bool ShowRuntimeBadge { get; init; }
|
|
public string RuntimeLabel { get; init; } = "";
|
|
public bool ShowLastCompleted { get; init; }
|
|
public string LastCompletedText { get; init; } = "";
|
|
public bool ShowCompactStrip { get; init; }
|
|
public string StripKind { get; init; } = "none";
|
|
public string StripText { get; init; } = "";
|
|
public string StripBackgroundHex { get; init; } = "";
|
|
public string StripBorderHex { get; init; } = "";
|
|
public string StripForegroundHex { get; init; } = "";
|
|
public bool ShowQuickStrip { get; init; }
|
|
public string QuickRunningText { get; init; } = "";
|
|
public string QuickHotText { get; init; } = "";
|
|
public bool QuickRunningActive { get; init; }
|
|
public bool QuickHotActive { get; init; }
|
|
public string QuickRunningBackgroundHex { get; init; } = "#F8FAFC";
|
|
public string QuickRunningBorderHex { get; init; } = "#E5E7EB";
|
|
public string QuickRunningForegroundHex { get; init; } = "#6B7280";
|
|
public string QuickHotBackgroundHex { get; init; } = "#F8FAFC";
|
|
public string QuickHotBorderHex { get; init; } = "#E5E7EB";
|
|
public string QuickHotForegroundHex { get; init; } = "#6B7280";
|
|
}
|
|
|
|
public ChatSessionStateService? ChatSession { get; private set; }
|
|
public SkillCatalogState Skills { get; } = new();
|
|
public McpCatalogState Mcp { get; } = new();
|
|
public PermissionPolicyState Permissions { get; } = new();
|
|
public AgentCatalogState AgentCatalog { get; } = new();
|
|
public TaskRunService TaskRuns { get; } = new();
|
|
public BackgroundJobService BackgroundJobs { get; } = new();
|
|
public TaskRunStore TaskStore => TaskRuns.Store;
|
|
public IReadOnlyList<TaskRunStore.TaskRun> ActiveTasks => TaskRuns.ActiveTasks;
|
|
public IReadOnlyList<TaskRunStore.TaskRun> RecentTasks => TaskRuns.RecentTasks;
|
|
public AgentRunState AgentRun { get; } = new();
|
|
private readonly Dictionary<string, AgentRunState> _agentRunsByTab = new(StringComparer.OrdinalIgnoreCase);
|
|
public List<AgentRunState> AgentRunHistory { get; } = new();
|
|
public List<PermissionEventState> PermissionHistory { get; } = new();
|
|
public List<HookEventState> HookHistory { get; } = new();
|
|
|
|
public event Action? StateChanged;
|
|
|
|
public AppStateService()
|
|
{
|
|
TaskRuns.Changed += NotifyStateChanged;
|
|
BackgroundJobs.Changed += NotifyStateChanged;
|
|
}
|
|
|
|
public void AttachChatSession(ChatSessionStateService session)
|
|
{
|
|
ChatSession = session;
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void LoadFromSettings(ISettingsService settings)
|
|
{
|
|
var llm = settings.Settings.Llm;
|
|
Skills.Enabled = llm.EnableSkillSystem;
|
|
Skills.FolderPath = llm.SkillsFolderPath ?? "";
|
|
|
|
Permissions.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
|
|
Permissions.AgentDecisionLevel = llm.AgentDecisionLevel ?? "detailed";
|
|
Permissions.ToolOverrideCount = llm.ToolPermissions?.Count ?? 0;
|
|
Permissions.ToolOverrides = llm.ToolPermissions?
|
|
.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
|
|
.ToList()
|
|
?? new List<KeyValuePair<string, string>>();
|
|
|
|
var servers = llm.McpServers ?? [];
|
|
Mcp.ConfiguredServerCount = servers.Count;
|
|
Mcp.EnabledServerCount = servers.Count(s => s.Enabled);
|
|
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RefreshSkillCatalog(IEnumerable<SkillDefinition> skills, string? folderPath, bool enabled)
|
|
{
|
|
var snapshot = skills.ToList();
|
|
Skills.Enabled = enabled;
|
|
Skills.FolderPath = folderPath ?? "";
|
|
Skills.LoadedCount = snapshot.Count;
|
|
Skills.AvailableCount = snapshot.Count(s => s.IsAvailable);
|
|
Skills.UnavailableCount = snapshot.Count - Skills.AvailableCount;
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RefreshMcpCatalog(IEnumerable<Models.McpServerEntry>? servers, int connectedServerCount, int registeredToolCount)
|
|
{
|
|
var snapshot = servers?.ToList() ?? [];
|
|
Mcp.ConfiguredServerCount = snapshot.Count;
|
|
Mcp.EnabledServerCount = snapshot.Count(s => s.Enabled);
|
|
Mcp.ConnectedServerCount = connectedServerCount;
|
|
Mcp.RegisteredToolCount = registeredToolCount;
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RefreshAgentCatalog(ToolRegistry registry)
|
|
{
|
|
var tools = registry.All.ToList();
|
|
AgentCatalog.RegisteredToolCount = tools.Count;
|
|
AgentCatalog.McpToolCount = tools.Count(t => t is McpTool);
|
|
AgentCatalog.SkillToolCount = tools.Count(t => t.GetType().Name.EndsWith("Skill", StringComparison.OrdinalIgnoreCase));
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void UpsertTask(string id, string kind, string title, string summary, string status = "running", string? filePath = null)
|
|
{
|
|
TaskRuns.StartOrUpdate(id, kind, title, summary, status, filePath);
|
|
}
|
|
|
|
public void CompleteTask(string id, string? summary = null, string status = "completed")
|
|
{
|
|
TaskRuns.Complete(id, summary, status);
|
|
}
|
|
|
|
public void ClearTasksByPrefix(string prefix, string? summary = null, string status = "completed")
|
|
{
|
|
TaskRuns.CompleteByPrefix(prefix, summary, status);
|
|
}
|
|
|
|
private static string NormalizeTabKey(string? tab)
|
|
=> string.IsNullOrWhiteSpace(tab) ? "" : tab.Trim();
|
|
|
|
private static void ApplyRunSnapshot(AgentRunState target, string runId, string status, string summary, int iteration)
|
|
{
|
|
if (!string.Equals(target.RunId, runId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
target.RunId = runId;
|
|
target.StartedAt = DateTime.Now;
|
|
}
|
|
|
|
target.Status = status;
|
|
target.Summary = summary;
|
|
target.LastIteration = iteration;
|
|
target.UpdatedAt = DateTime.Now;
|
|
}
|
|
|
|
private AgentRunState GetOrCreateAgentRunForTab(string? tab)
|
|
{
|
|
var key = NormalizeTabKey(tab);
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
return AgentRun;
|
|
|
|
if (_agentRunsByTab.TryGetValue(key, out var existing))
|
|
return existing;
|
|
|
|
var created = new AgentRunState();
|
|
_agentRunsByTab[key] = created;
|
|
return created;
|
|
}
|
|
|
|
public AgentRunState? GetAgentRunForTab(string? tab)
|
|
{
|
|
var key = NormalizeTabKey(tab);
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
return AgentRun;
|
|
|
|
return _agentRunsByTab.TryGetValue(key, out var state) ? state : null;
|
|
}
|
|
|
|
public void UpsertAgentRun(string runId, string status, string summary, int iteration, string? tab = null)
|
|
{
|
|
ApplyRunSnapshot(AgentRun, runId, status, summary, iteration);
|
|
if (!string.IsNullOrWhiteSpace(tab))
|
|
ApplyRunSnapshot(GetOrCreateAgentRunForTab(tab), runId, status, summary, iteration);
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void CompleteAgentRun(string runId, string status, string summary, int iteration, string? tab = null)
|
|
{
|
|
ApplyRunSnapshot(AgentRun, runId, status, summary, iteration);
|
|
if (!string.IsNullOrWhiteSpace(tab))
|
|
ApplyRunSnapshot(GetOrCreateAgentRunForTab(tab), runId, status, summary, iteration);
|
|
AgentRunHistory.Insert(0, new AgentRunState
|
|
{
|
|
RunId = AgentRun.RunId,
|
|
Status = AgentRun.Status,
|
|
Summary = AgentRun.Summary,
|
|
LastIteration = AgentRun.LastIteration,
|
|
StartedAt = AgentRun.StartedAt,
|
|
UpdatedAt = AgentRun.UpdatedAt,
|
|
});
|
|
if (AgentRunHistory.Count > 12)
|
|
AgentRunHistory.RemoveRange(12, AgentRunHistory.Count - 12);
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RestoreAgentRunHistory(IEnumerable<ChatAgentRunRecord>? history)
|
|
{
|
|
AgentRunHistory.Clear();
|
|
_agentRunsByTab.Clear();
|
|
if (history != null)
|
|
{
|
|
foreach (var item in history
|
|
.OrderByDescending(x => x.UpdatedAt)
|
|
.Take(12))
|
|
{
|
|
AgentRunHistory.Add(new AgentRunState
|
|
{
|
|
RunId = item.RunId,
|
|
Status = item.Status,
|
|
Summary = item.Summary,
|
|
LastIteration = item.LastIteration,
|
|
StartedAt = item.StartedAt,
|
|
UpdatedAt = item.UpdatedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RestoreCurrentAgentRun(
|
|
IEnumerable<ChatExecutionEvent>? executionEvents,
|
|
IEnumerable<ChatAgentRunRecord>? runHistory)
|
|
{
|
|
AgentRun.RunId = "";
|
|
AgentRun.Status = "idle";
|
|
AgentRun.Summary = "";
|
|
AgentRun.LastIteration = 0;
|
|
AgentRun.StartedAt = DateTime.Now;
|
|
AgentRun.UpdatedAt = DateTime.Now;
|
|
|
|
var latestEvent = executionEvents?
|
|
.Where(evt => !string.IsNullOrWhiteSpace(evt.RunId))
|
|
.OrderByDescending(evt => evt.Timestamp)
|
|
.ThenByDescending(evt => evt.Iteration)
|
|
.FirstOrDefault();
|
|
|
|
if (latestEvent != null)
|
|
{
|
|
var isTerminal = string.Equals(latestEvent.Type, nameof(AgentEventType.Complete), StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(latestEvent.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!isTerminal)
|
|
{
|
|
AgentRun.RunId = latestEvent.RunId;
|
|
AgentRun.Status = "running";
|
|
AgentRun.Summary = string.IsNullOrWhiteSpace(latestEvent.Summary) ? "실행 복원됨" : latestEvent.Summary;
|
|
AgentRun.LastIteration = latestEvent.Iteration;
|
|
AgentRun.StartedAt = latestEvent.Timestamp == default ? DateTime.Now : latestEvent.Timestamp;
|
|
AgentRun.UpdatedAt = latestEvent.Timestamp == default ? DateTime.Now : latestEvent.Timestamp;
|
|
NotifyStateChanged();
|
|
return;
|
|
}
|
|
}
|
|
|
|
var latestRun = runHistory?
|
|
.OrderByDescending(GetRunSortTicks)
|
|
.FirstOrDefault();
|
|
|
|
if (latestRun != null)
|
|
{
|
|
AgentRun.RunId = latestRun.RunId;
|
|
AgentRun.Status = latestRun.Status;
|
|
AgentRun.Summary = latestRun.Summary;
|
|
AgentRun.LastIteration = latestRun.LastIteration;
|
|
AgentRun.StartedAt = latestRun.StartedAt == default ? DateTime.Now : latestRun.StartedAt;
|
|
AgentRun.UpdatedAt = latestRun.UpdatedAt == default ? DateTime.Now : latestRun.UpdatedAt;
|
|
}
|
|
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void RestoreRecentTasks(IEnumerable<ChatExecutionEvent>? history)
|
|
{
|
|
TaskRuns.RestoreRecentFromExecutionEvents(history);
|
|
}
|
|
|
|
public void ApplyAgentEvent(AgentEvent evt)
|
|
=> ApplyAgentEvent(evt, null);
|
|
|
|
public void ApplyAgentEvent(AgentEvent evt, string? tab)
|
|
{
|
|
TaskRuns.ApplyAgentEvent(evt);
|
|
if (evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied)
|
|
AppendPermissionEvent(evt);
|
|
if (evt.Type is AgentEventType.HookResult)
|
|
AppendHookEvent(evt);
|
|
|
|
switch (evt.Type)
|
|
{
|
|
case AgentEventType.Thinking:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"running",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "응답을 준비하는 중" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
break;
|
|
case AgentEventType.Planning:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"running",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 계획을 세우는 중" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
break;
|
|
case AgentEventType.Complete:
|
|
CompleteAgentRun(
|
|
evt.RunId,
|
|
"completed",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
break;
|
|
case AgentEventType.Error:
|
|
if (string.IsNullOrWhiteSpace(evt.ToolName))
|
|
{
|
|
CompleteAgentRun(
|
|
evt.RunId,
|
|
"failed",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
}
|
|
break;
|
|
case AgentEventType.Paused:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"paused",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 일시중지" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
break;
|
|
case AgentEventType.Resumed:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"running",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 재개" : evt.Summary,
|
|
evt.Iteration,
|
|
tab);
|
|
break;
|
|
}
|
|
}
|
|
|
|
public void ApplySubAgentStatus(SubAgentStatusEvent evt)
|
|
{
|
|
TaskRuns.ApplySubAgentStatus(evt);
|
|
var id = string.IsNullOrWhiteSpace(evt.Id) ? "background:subagent:unknown" : $"background:subagent:{evt.Id}";
|
|
var title = string.IsNullOrWhiteSpace(evt.Id) ? "sub-agent" : evt.Id;
|
|
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? evt.Task : evt.Summary;
|
|
switch (evt.Status)
|
|
{
|
|
case SubAgentRunStatus.Started:
|
|
BackgroundJobs.Upsert(id, "subagent", title, summary, "running");
|
|
break;
|
|
case SubAgentRunStatus.Completed:
|
|
BackgroundJobs.Complete(id, summary, "completed");
|
|
break;
|
|
case SubAgentRunStatus.Failed:
|
|
BackgroundJobs.Complete(id, summary, "failed");
|
|
break;
|
|
}
|
|
}
|
|
|
|
public TaskSummaryState GetTaskSummary()
|
|
{
|
|
var taskSummary = TaskRuns.GetSummary();
|
|
return new TaskSummaryState
|
|
{
|
|
ActiveCount = taskSummary.ActiveCount,
|
|
PendingPermissionCount = taskSummary.PendingPermissionCount,
|
|
LatestRecentTask = taskSummary.LatestRecentTask,
|
|
LatestFailedRun = AgentRunHistory.FirstOrDefault(run =>
|
|
string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase)),
|
|
};
|
|
}
|
|
|
|
public AgentRunState? GetLatestFailedRun()
|
|
=> AgentRunHistory.FirstOrDefault(run =>
|
|
string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
|
|
|
public IReadOnlyList<PermissionEventState> GetRecentPermissionEvents(int take = 6)
|
|
=> PermissionHistory
|
|
.Take(Math.Max(0, take))
|
|
.ToList();
|
|
|
|
public PermissionEventState? GetLatestDeniedPermission()
|
|
=> PermissionHistory.FirstOrDefault(x => string.Equals(x.Status, "denied", StringComparison.OrdinalIgnoreCase));
|
|
|
|
public IReadOnlyList<HookEventState> GetRecentHookEvents(int take = 6)
|
|
=> HookHistory
|
|
.Take(Math.Max(0, take))
|
|
.ToList();
|
|
|
|
public BackgroundJobSummaryState GetBackgroundJobSummary()
|
|
=> new()
|
|
{
|
|
ActiveCount = BackgroundJobs.ActiveJobs.Count,
|
|
LatestRecent = BackgroundJobs.RecentJobs.FirstOrDefault(),
|
|
};
|
|
|
|
public IReadOnlyList<BackgroundJobService.BackgroundJobState> GetRecentBackgroundJobs(int take = 6)
|
|
=> BackgroundJobs.RecentJobs
|
|
.Take(Math.Max(0, take))
|
|
.ToList();
|
|
|
|
public IReadOnlyList<BackgroundJobService.BackgroundJobState> GetActiveBackgroundJobs(int take = 6)
|
|
=> BackgroundJobs.ActiveJobs
|
|
.Take(Math.Max(0, take))
|
|
.ToList();
|
|
|
|
public PermissionSummaryState GetPermissionSummary(ChatConversation? conversation = null)
|
|
{
|
|
var effective = PermissionModeCatalog.NormalizeGlobalMode(conversation?.Permission);
|
|
var defaultMode = PermissionModeCatalog.NormalizeGlobalMode(Permissions.FilePermission);
|
|
if (string.IsNullOrWhiteSpace(conversation?.Permission))
|
|
effective = defaultMode;
|
|
|
|
var risk = PermissionModeCatalog.IsBypassPermissions(effective)
|
|
? "critical"
|
|
: PermissionModeCatalog.IsAcceptEdits(effective)
|
|
? "high"
|
|
: string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
|
|
? "locked"
|
|
: string.Equals(effective, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)
|
|
? "guarded"
|
|
: "normal";
|
|
|
|
var description = effective switch
|
|
{
|
|
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
|
|
"Deny" => "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.",
|
|
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
|
|
"BypassPermissions" => "같은 실행 안의 권한 확인을 최대한 생략하지만, 사내 모드에서 지정 경로 밖 접근은 계속 승인받습니다.",
|
|
_ => "파일 작업 전마다 사용자 확인을 요청합니다.",
|
|
};
|
|
|
|
return new PermissionSummaryState
|
|
{
|
|
EffectiveMode = effective,
|
|
DefaultMode = defaultMode,
|
|
OverrideCount = Permissions.ToolOverrideCount,
|
|
RiskLevel = risk,
|
|
Description = description,
|
|
TopOverrides = Permissions.ToolOverrides.Take(4).ToList(),
|
|
};
|
|
}
|
|
|
|
public OperationalStatusState GetOperationalStatus(string tab)
|
|
{
|
|
var taskSummary = GetTaskSummary();
|
|
var queueSummary = GetDraftQueueSummary(tab);
|
|
var backgroundSummary = GetBackgroundJobSummary();
|
|
var latestDeniedPermission = GetLatestDeniedPermission();
|
|
|
|
var showRuntimeBadge = taskSummary.HasActiveTasks
|
|
|| taskSummary.PendingPermissionCount > 0
|
|
|| backgroundSummary.ActiveCount > 0;
|
|
|
|
var runtimeLabel = taskSummary.PendingPermissionCount > 0
|
|
? $"승인 대기 {taskSummary.PendingPermissionCount}"
|
|
: taskSummary.HasActiveTasks
|
|
? $"실행 중 {taskSummary.ActiveCount}"
|
|
: backgroundSummary.ActiveCount > 0
|
|
? $"백그라운드 {backgroundSummary.ActiveCount}"
|
|
: "실행 중 0";
|
|
|
|
var lastCompletedText = taskSummary.LatestRecentTask == null
|
|
? ""
|
|
: $"마지막 {taskSummary.LatestRecentTask.Status}: {Truncate(taskSummary.LatestRecentTask.Summary, 44)}";
|
|
|
|
string stripKind;
|
|
string stripText;
|
|
|
|
if (taskSummary.HasActiveTasks)
|
|
{
|
|
stripKind = taskSummary.PendingPermissionCount > 0 ? "permission_waiting" : "running";
|
|
stripText = taskSummary.PendingPermissionCount > 0
|
|
? $"권한 대기 {taskSummary.PendingPermissionCount}"
|
|
: $"진행 중 {taskSummary.ActiveCount}";
|
|
}
|
|
else if (taskSummary.LatestFailedRun != null)
|
|
{
|
|
stripKind = "failed_run";
|
|
stripText = $"최근 실패 {FormatDate(taskSummary.LatestFailedRun.UpdatedAt)}";
|
|
}
|
|
else if (backgroundSummary.ActiveCount > 0)
|
|
{
|
|
stripKind = "background";
|
|
stripText = backgroundSummary.LatestRecent == null
|
|
? $"백그라운드 작업 {backgroundSummary.ActiveCount}"
|
|
: $"백그라운드 작업 {backgroundSummary.ActiveCount} · 최근 {Truncate(backgroundSummary.LatestRecent.Title, 18)}";
|
|
}
|
|
else if (latestDeniedPermission != null)
|
|
{
|
|
stripKind = "permission_denied";
|
|
stripText = $"최근 권한 거부 · {Truncate(latestDeniedPermission.ToolName, 18)}";
|
|
}
|
|
else
|
|
{
|
|
stripKind = "none";
|
|
stripText = "";
|
|
}
|
|
|
|
return new OperationalStatusState
|
|
{
|
|
ShowRuntimeBadge = showRuntimeBadge,
|
|
RuntimeLabel = runtimeLabel,
|
|
ShowLastCompleted = taskSummary.LatestRecentTask != null,
|
|
LastCompletedText = lastCompletedText,
|
|
StripKind = stripKind,
|
|
StripText = stripText,
|
|
};
|
|
}
|
|
|
|
public DraftQueueSummaryState GetDraftQueueSummary(string tab)
|
|
{
|
|
var summary = ChatSession?.GetDraftQueueSummary(tab);
|
|
if (summary == null)
|
|
return new DraftQueueSummaryState();
|
|
|
|
return new DraftQueueSummaryState
|
|
{
|
|
TotalCount = summary.TotalCount,
|
|
QueuedCount = summary.QueuedCount,
|
|
RunningCount = summary.RunningCount,
|
|
BlockedCount = summary.BlockedCount,
|
|
FailedCount = summary.FailedCount,
|
|
CompletedCount = summary.CompletedCount,
|
|
NextItem = summary.NextItem,
|
|
NextReadyAt = summary.NextReadyAt,
|
|
};
|
|
}
|
|
|
|
public OperationalStatusPresentationState GetOperationalStatusPresentation(
|
|
string tab,
|
|
bool hasLiveRuntimeActivity,
|
|
int runningConversationCount,
|
|
int spotlightConversationCount,
|
|
bool runningOnlyFilter,
|
|
bool sortConversationsByRecent)
|
|
{
|
|
var status = GetOperationalStatus(tab);
|
|
return OperationalStatusPresentationCatalog.Resolve(
|
|
status,
|
|
tab,
|
|
hasLiveRuntimeActivity,
|
|
runningConversationCount,
|
|
spotlightConversationCount,
|
|
runningOnlyFilter,
|
|
sortConversationsByRecent);
|
|
}
|
|
|
|
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
|
|
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();
|
|
|
|
public IReadOnlyList<AgentRunState> GetRecentAgentRuns(int take = 4)
|
|
=> AgentRunHistory
|
|
.Take(Math.Max(0, take))
|
|
.ToList();
|
|
|
|
public AgentRunState? GetAgentRunById(string? runId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(runId))
|
|
return null;
|
|
|
|
if (string.Equals(AgentRun.RunId, runId, StringComparison.OrdinalIgnoreCase))
|
|
return AgentRun;
|
|
|
|
return AgentRunHistory.FirstOrDefault(run =>
|
|
string.Equals(run.RunId, runId, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
public AgentRunState? GetLatestConversationRun(IEnumerable<ChatAgentRunRecord>? history)
|
|
{
|
|
var latestRunId = history?
|
|
.OrderByDescending(GetRunSortTicks)
|
|
.FirstOrDefault()?
|
|
.RunId;
|
|
return GetAgentRunById(latestRunId);
|
|
}
|
|
|
|
public ConversationRunSummaryState GetConversationRunSummary(IEnumerable<ChatAgentRunRecord>? history)
|
|
{
|
|
var runs = (history ?? [])
|
|
.OrderByDescending(GetRunSortTicks)
|
|
.ToList();
|
|
var lastFailedRun = runs
|
|
.Where(x => string.Equals(x.Status, "failed", StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(GetRunSortTicks)
|
|
.FirstOrDefault();
|
|
var lastCompletedRun = runs
|
|
.Where(x => string.Equals(x.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(GetRunSortTicks)
|
|
.FirstOrDefault();
|
|
|
|
return new ConversationRunSummaryState
|
|
{
|
|
AgentRunCount = runs.Count,
|
|
FailedAgentRunCount = runs.Count(x => string.Equals(x.Status, "failed", StringComparison.OrdinalIgnoreCase)),
|
|
LastAgentRunSummary = runs.FirstOrDefault()?.Summary ?? "",
|
|
LastFailedAt = lastFailedRun?.UpdatedAt,
|
|
LastCompletedAt = lastCompletedRun?.UpdatedAt,
|
|
};
|
|
}
|
|
|
|
public RunDetailSummaryState GetRunDetailSummary(IEnumerable<ChatExecutionEvent>? history, string? runId, int eventTake = 3, int fileTake = 2)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(runId))
|
|
return new RunDetailSummaryState();
|
|
|
|
var events = history?
|
|
.Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(evt => evt.Timestamp)
|
|
.Take(Math.Max(0, eventTake))
|
|
.ToList()
|
|
?? new List<ChatExecutionEvent>();
|
|
|
|
var files = history?
|
|
.Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(evt.FilePath))
|
|
.OrderByDescending(evt => evt.Timestamp)
|
|
.Select(evt => evt.FilePath!)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(Math.Max(0, fileTake))
|
|
.ToList()
|
|
?? new List<string>();
|
|
|
|
return new RunDetailSummaryState
|
|
{
|
|
RunId = runId,
|
|
Events = events,
|
|
FilePaths = files,
|
|
};
|
|
}
|
|
|
|
public RunPlanHistoryState GetRunPlanHistory(IEnumerable<ChatExecutionEvent>? history, string? runId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(runId))
|
|
return new RunPlanHistoryState();
|
|
|
|
var runEvents = history?
|
|
.Where(evt => string.Equals(evt.RunId, runId, StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(evt => evt.Timestamp)
|
|
.ThenBy(evt => evt.Iteration)
|
|
.ToList()
|
|
?? new List<ChatExecutionEvent>();
|
|
|
|
var planning = runEvents
|
|
.Where(evt => string.Equals(evt.Type, "Planning", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
var decisions = runEvents
|
|
.Where(evt => string.Equals(evt.Type, "Decision", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
var original = planning.FirstOrDefault();
|
|
var revised = planning.Skip(1).LastOrDefault();
|
|
var approved = decisions
|
|
.LastOrDefault(evt => evt.Summary.Contains("계획 승인", StringComparison.OrdinalIgnoreCase));
|
|
|
|
var originalSteps = NormalizePlanSteps(original?.Steps);
|
|
var revisedSteps = NormalizePlanSteps(revised?.Steps);
|
|
var approvedSteps = NormalizePlanSteps(approved?.Steps);
|
|
if (approvedSteps.Count == 0)
|
|
approvedSteps = revisedSteps.Count > 0 ? revisedSteps.ToList() : originalSteps.ToList();
|
|
|
|
return new RunPlanHistoryState
|
|
{
|
|
OriginalSummary = original?.Summary,
|
|
OriginalSteps = originalSteps,
|
|
RevisedSummary = revised?.Summary,
|
|
RevisedSteps = revisedSteps,
|
|
FinalApprovedSummary = approved?.Summary,
|
|
FinalApprovedSteps = approvedSteps,
|
|
};
|
|
}
|
|
|
|
public RunDisplayState GetRunDisplay(AgentRunState run)
|
|
{
|
|
return new RunDisplayState
|
|
{
|
|
HeaderText = $"run {ShortRunId(run.RunId)} · {GetRunStatusLabel(run.Status)}",
|
|
MetaText = $"iteration {run.LastIteration} · {run.UpdatedAt:HH:mm:ss}",
|
|
SummaryText = string.IsNullOrWhiteSpace(run.Summary) ? "요약 없음" : run.Summary,
|
|
};
|
|
}
|
|
|
|
public string FormatExecutionEventLine(ChatExecutionEvent evt, int maxSummaryLength = 52)
|
|
{
|
|
var label = evt.Type switch
|
|
{
|
|
"PermissionRequest" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 요청" : $"{evt.ToolName} 권한 요청",
|
|
"PermissionGranted" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 승인" : $"{evt.ToolName} 권한 승인",
|
|
"PermissionDenied" => string.IsNullOrWhiteSpace(evt.ToolName) ? "권한 거부" : $"{evt.ToolName} 권한 거부",
|
|
"ToolCall" => string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 호출" : $"{evt.ToolName} 호출",
|
|
"ToolResult" => string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 결과" : $"{evt.ToolName} 결과",
|
|
"Error" => "오류",
|
|
"Complete" => "완료",
|
|
"Planning" => "계획",
|
|
"Decision" => ResolveDecisionLineLabel(evt.Summary),
|
|
"Thinking" => "생각",
|
|
_ => evt.Type,
|
|
};
|
|
|
|
var summary = string.IsNullOrWhiteSpace(evt.Summary)
|
|
? label
|
|
: $"{label} · {Truncate(evt.Summary, maxSummaryLength)}";
|
|
return $"{evt.Timestamp:HH:mm:ss} · {summary}";
|
|
}
|
|
|
|
public string FormatPermissionEventLine(PermissionEventState evt, int maxSummaryLength = 64)
|
|
{
|
|
var label = evt.Status switch
|
|
{
|
|
"granted" => "권한 승인",
|
|
"denied" => "권한 거부",
|
|
_ => "권한 요청",
|
|
};
|
|
var tool = string.IsNullOrWhiteSpace(evt.ToolName) ? label : $"{evt.ToolName} · {label}";
|
|
var summary = string.IsNullOrWhiteSpace(evt.Summary)
|
|
? tool
|
|
: $"{tool} · {Truncate(evt.Summary, maxSummaryLength)}";
|
|
return $"{evt.Timestamp:HH:mm:ss} · {summary}";
|
|
}
|
|
|
|
public string FormatHookEventLine(HookEventState evt, int maxSummaryLength = 72)
|
|
{
|
|
var tool = string.IsNullOrWhiteSpace(evt.ToolName) ? "hook" : $"{evt.ToolName} hook";
|
|
var status = evt.Success ? "성공" : "실패";
|
|
var summary = string.IsNullOrWhiteSpace(evt.Summary)
|
|
? $"{tool} · {status}"
|
|
: $"{tool} · {status} · {Truncate(evt.Summary, maxSummaryLength)}";
|
|
return $"{evt.Timestamp:HH:mm:ss} · {summary}";
|
|
}
|
|
|
|
public string GetRunStatusLabel(string? status)
|
|
=> status switch
|
|
{
|
|
"completed" => "완료",
|
|
"failed" => "실패",
|
|
"paused" => "일시중지",
|
|
_ => "진행 중",
|
|
};
|
|
|
|
private static long GetRunSortTicks(ChatAgentRunRecord run)
|
|
{
|
|
var when = run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt;
|
|
return when.Ticks;
|
|
}
|
|
|
|
private static string GetAgentTaskKey(AgentEvent evt)
|
|
=> string.IsNullOrWhiteSpace(evt.RunId) ? "agent:main" : $"agent:{evt.RunId}";
|
|
|
|
private static string ShortRunId(string? runId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(runId))
|
|
return "main";
|
|
|
|
return runId.Length <= 8 ? runId : runId[..8];
|
|
}
|
|
|
|
private static string FormatDate(DateTime value)
|
|
{
|
|
var span = DateTime.Now - value;
|
|
if (span.TotalMinutes < 1)
|
|
return "방금";
|
|
if (span.TotalHours < 1)
|
|
return $"{Math.Max(1, (int)span.TotalMinutes)}분 전";
|
|
if (span.TotalDays < 1)
|
|
return $"{Math.Max(1, (int)span.TotalHours)}시간 전";
|
|
return value.ToString("MM-dd HH:mm");
|
|
}
|
|
|
|
private static string Truncate(string? text, int max)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
return "";
|
|
|
|
return text.Length <= max ? text : text[..max] + "…";
|
|
}
|
|
|
|
private static List<string> NormalizePlanSteps(IEnumerable<string>? steps)
|
|
{
|
|
if (steps == null)
|
|
return new List<string>();
|
|
|
|
return steps
|
|
.Select(step => step?.Trim() ?? "")
|
|
.Where(step => !string.IsNullOrWhiteSpace(step))
|
|
.ToList();
|
|
}
|
|
|
|
private static string ResolveDecisionLineLabel(string? summary)
|
|
{
|
|
var text = summary?.Trim() ?? "";
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return "의사결정";
|
|
|
|
if (text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase))
|
|
return "계획 승인 대기";
|
|
|
|
if (text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase))
|
|
return "계획 승인";
|
|
|
|
if (text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase)
|
|
|| text.Contains("취소", StringComparison.OrdinalIgnoreCase))
|
|
return "계획 반려";
|
|
|
|
return "의사결정";
|
|
}
|
|
|
|
private static string GetToolTaskKey(AgentEvent evt)
|
|
{
|
|
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName;
|
|
return string.IsNullOrWhiteSpace(evt.RunId)
|
|
? $"tool:{suffix}"
|
|
: $"tool:{evt.RunId}:{suffix}";
|
|
}
|
|
|
|
private static string GetPermissionTaskKey(AgentEvent evt)
|
|
{
|
|
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName;
|
|
return string.IsNullOrWhiteSpace(evt.RunId)
|
|
? $"permission:{suffix}"
|
|
: $"permission:{evt.RunId}:{suffix}";
|
|
}
|
|
|
|
private static TaskRunStore.TaskRun? TryCreateTaskFromExecutionEvent(ChatExecutionEvent item)
|
|
{
|
|
static string BuildScopedId(string prefix, ChatExecutionEvent evt)
|
|
{
|
|
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "main" : evt.ToolName;
|
|
return string.IsNullOrWhiteSpace(evt.RunId)
|
|
? $"{prefix}:{suffix}"
|
|
: $"{prefix}:{evt.RunId}:{suffix}";
|
|
}
|
|
|
|
if (string.Equals(item.Type, nameof(AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(item.Type, nameof(AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new TaskRunStore.TaskRun
|
|
{
|
|
Id = BuildScopedId("permission", item),
|
|
Kind = "permission",
|
|
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "권한" : $"{item.ToolName} 권한",
|
|
Summary = item.Summary,
|
|
Status = string.Equals(item.Type, nameof(AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed",
|
|
StartedAt = item.Timestamp,
|
|
UpdatedAt = item.Timestamp,
|
|
FilePath = item.FilePath,
|
|
};
|
|
}
|
|
|
|
if (string.Equals(item.Type, nameof(AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new TaskRunStore.TaskRun
|
|
{
|
|
Id = BuildScopedId("tool", item),
|
|
Kind = "tool",
|
|
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "tool" : item.ToolName,
|
|
Summary = item.Summary,
|
|
Status = item.Success ? "completed" : "failed",
|
|
StartedAt = item.Timestamp,
|
|
UpdatedAt = item.Timestamp,
|
|
FilePath = item.FilePath,
|
|
};
|
|
}
|
|
|
|
if (string.Equals(item.Type, nameof(AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(item.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new TaskRunStore.TaskRun
|
|
{
|
|
Id = BuildScopedId("agent", item),
|
|
Kind = "agent",
|
|
Title = "main",
|
|
Summary = item.Summary,
|
|
Status = string.Equals(item.Type, nameof(AgentEventType.Error), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed",
|
|
StartedAt = item.Timestamp,
|
|
UpdatedAt = item.Timestamp,
|
|
FilePath = item.FilePath,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void AppendPermissionEvent(AgentEvent evt)
|
|
{
|
|
var status = evt.Type switch
|
|
{
|
|
AgentEventType.PermissionGranted => "granted",
|
|
AgentEventType.PermissionDenied => "denied",
|
|
_ => "requested",
|
|
};
|
|
|
|
PermissionHistory.Insert(0, new PermissionEventState
|
|
{
|
|
RunId = evt.RunId,
|
|
ToolName = evt.ToolName ?? "",
|
|
Status = status,
|
|
Summary = evt.Summary ?? "",
|
|
Timestamp = evt.Timestamp,
|
|
});
|
|
|
|
if (PermissionHistory.Count > 20)
|
|
PermissionHistory.RemoveRange(20, PermissionHistory.Count - 20);
|
|
}
|
|
|
|
private void AppendHookEvent(AgentEvent evt)
|
|
{
|
|
HookHistory.Insert(0, new HookEventState
|
|
{
|
|
RunId = evt.RunId,
|
|
ToolName = evt.ToolName ?? "",
|
|
Success = evt.Success,
|
|
Summary = evt.Summary ?? "",
|
|
Timestamp = evt.Timestamp,
|
|
});
|
|
|
|
if (HookHistory.Count > 20)
|
|
HookHistory.RemoveRange(20, HookHistory.Count - 20);
|
|
}
|
|
|
|
private void NotifyStateChanged() => StateChanged?.Invoke();
|
|
}
|