Some checks failed
Release Gate / gate (push) Has been cancelled
- .gitignore에 bin/obj/publish 및 IDE/OS/비밀정보 패턴 추가 - Git 인덱스에서 publish 및 src 하위 bin/obj 빌드 부산물 추적을 해제하여 저장소 노이즈를 정리 - DraftQueue를 실행 대기/최근 결과 섹션과 상태 요약 pill 구조로 재정리 - composer 상단 모델/컨텍스트/프리셋 줄과 하단 작업 위치 칩 UI를 더 평평한 시각 언어로 통일 - 워크스페이스·브랜치·워크트리 패널에 공통 row 및 요약 strip을 적용해 panel UX를 정돈 - README.md와 docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md, AGENTS.md 이력을 갱신 검증 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ - 경고 0개, 오류 0개
1022 lines
39 KiB
C#
1022 lines
39 KiB
C#
using AxCopilot.Services.Agent;
|
|
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// 앱 전역에서 공유되는 상태 요약 저장소.
|
|
/// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아
|
|
/// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다.
|
|
/// </summary>
|
|
public sealed class AppStateService
|
|
{
|
|
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 string PlanMode { get; set; } = "off";
|
|
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 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();
|
|
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(SettingsService 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.PlanMode = llm.PlanMode ?? "off";
|
|
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);
|
|
}
|
|
|
|
public void UpsertAgentRun(string runId, string status, string summary, int iteration)
|
|
{
|
|
if (!string.Equals(AgentRun.RunId, runId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AgentRun.RunId = runId;
|
|
AgentRun.StartedAt = DateTime.Now;
|
|
}
|
|
|
|
AgentRun.Status = status;
|
|
AgentRun.Summary = summary;
|
|
AgentRun.LastIteration = iteration;
|
|
AgentRun.UpdatedAt = DateTime.Now;
|
|
NotifyStateChanged();
|
|
}
|
|
|
|
public void CompleteAgentRun(string runId, string status, string summary, int iteration)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(runId))
|
|
AgentRun.RunId = runId;
|
|
|
|
AgentRun.Status = status;
|
|
AgentRun.Summary = summary;
|
|
AgentRun.LastIteration = iteration;
|
|
AgentRun.UpdatedAt = DateTime.Now;
|
|
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();
|
|
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)
|
|
{
|
|
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);
|
|
break;
|
|
case AgentEventType.Planning:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"running",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 계획을 세우는 중" : evt.Summary,
|
|
evt.Iteration);
|
|
break;
|
|
case AgentEventType.Complete:
|
|
CompleteAgentRun(
|
|
evt.RunId,
|
|
"completed",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
|
|
evt.Iteration);
|
|
break;
|
|
case AgentEventType.Error:
|
|
if (string.IsNullOrWhiteSpace(evt.ToolName))
|
|
{
|
|
CompleteAgentRun(
|
|
evt.RunId,
|
|
"failed",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary,
|
|
evt.Iteration);
|
|
}
|
|
break;
|
|
case AgentEventType.Paused:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"paused",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 일시중지" : evt.Summary,
|
|
evt.Iteration);
|
|
break;
|
|
case AgentEventType.Resumed:
|
|
UpsertAgentRun(
|
|
evt.RunId,
|
|
"running",
|
|
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 재개" : evt.Summary,
|
|
evt.Iteration);
|
|
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
|
|
|| queueSummary.QueuedCount > 0
|
|
|| queueSummary.RunningCount > 0
|
|
|| queueSummary.BlockedCount > 0
|
|
|| backgroundSummary.ActiveCount > 0;
|
|
|
|
var runtimeLabel = taskSummary.PendingPermissionCount > 0
|
|
? $"승인 대기 {taskSummary.PendingPermissionCount}"
|
|
: taskSummary.HasActiveTasks
|
|
? $"실행 중 {taskSummary.ActiveCount}"
|
|
: queueSummary.RunningCount > 0
|
|
? $"큐 실행 {queueSummary.RunningCount}"
|
|
: backgroundSummary.ActiveCount > 0
|
|
? $"백그라운드 {backgroundSummary.ActiveCount}"
|
|
: queueSummary.QueuedCount > 0
|
|
? $"대기열 {queueSummary.QueuedCount}"
|
|
: queueSummary.BlockedCount > 0
|
|
? $"재시도 대기 {queueSummary.BlockedCount}"
|
|
: "실행 중 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 (queueSummary.RunningCount > 0 || queueSummary.QueuedCount > 0)
|
|
{
|
|
stripKind = "queue";
|
|
stripText = queueSummary.RunningCount > 0
|
|
? $"큐 실행 중 {queueSummary.RunningCount} · 다음 {Truncate(queueSummary.NextItem?.Text, 24)}"
|
|
: $"대기열 {queueSummary.QueuedCount} · 다음 {Truncate(queueSummary.NextItem?.Text, 24)}";
|
|
}
|
|
else if (queueSummary.BlockedCount > 0)
|
|
{
|
|
stripKind = "queue_blocked";
|
|
stripText = queueSummary.NextReadyAt.HasValue
|
|
? $"재시도 대기 {queueSummary.BlockedCount} · {FormatDate(queueSummary.NextReadyAt.Value)}"
|
|
: $"재시도 대기 {queueSummary.BlockedCount}";
|
|
}
|
|
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 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();
|
|
}
|