Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,616 @@
namespace AxCopilot.Services;
/// <summary>
/// Executes task-state tracking behind a single service boundary.
/// AppStateService and UI layers should prefer this service over direct store access.
/// </summary>
public sealed class TaskRunService
{
public sealed class TaskSummaryState
{
public int ActiveCount { get; init; }
public int PendingPermissionCount { get; init; }
public TaskRunStore.TaskRun? LatestRecentTask { get; init; }
public bool HasActiveTasks => ActiveCount > 0;
}
private readonly TaskRunStore _store;
public TaskRunService()
: this(new TaskRunStore())
{
}
public TaskRunService(TaskRunStore store)
{
_store = store;
_store.Changed += () => Changed?.Invoke();
}
public TaskRunStore Store => _store;
public IReadOnlyList<TaskRunStore.TaskRun> ActiveTasks => _store.ActiveTasks;
public IReadOnlyList<TaskRunStore.TaskRun> RecentTasks => _store.RecentTasks;
public event Action? Changed;
public void StartOrUpdate(string id, string kind, string title, string summary, string status = "running", string? filePath = null)
{
_store.Upsert(id, kind, title, summary, status, filePath);
}
public void Complete(string id, string? summary = null, string status = "completed")
{
_store.Complete(id, summary, status);
}
public void CompleteByPrefix(string prefix, string? summary = null, string status = "completed")
{
_store.CompleteByPrefix(prefix, summary, status);
}
public void RestoreRecent(IEnumerable<TaskRunStore.TaskRun>? recent)
{
_store.RestoreRecent(recent);
}
public void StartAgentRun(string runId, string summary)
{
var evt = new Agent.AgentEvent { RunId = runId };
StartOrUpdate(GetAgentTaskKey(evt), "agent", "main", summary, "running");
}
public void CompleteAgentRun(string runId, string summary, bool success)
{
var evt = new Agent.AgentEvent { RunId = runId };
Complete(GetAgentTaskKey(evt), summary, success ? "completed" : "failed");
}
public void StartToolRun(string runId, string toolName, string summary, string? filePath = null, bool skill = false)
{
var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName };
StartOrUpdate(
GetToolTaskKey(evt),
skill ? "skill" : "tool",
string.IsNullOrWhiteSpace(toolName) ? (skill ? "skill" : "tool") : toolName,
summary,
"running",
filePath);
}
public void CompleteToolRun(string runId, string toolName, string summary, bool success)
{
var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName };
Complete(GetToolTaskKey(evt), summary, success ? "completed" : "failed");
}
public void StartPermissionRequest(string runId, string toolName, string summary, string? filePath = null)
{
var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName };
StartOrUpdate(
GetPermissionTaskKey(evt),
"permission",
string.IsNullOrWhiteSpace(toolName) ? "권한 요청" : $"{toolName} 권한",
summary,
"waiting",
filePath);
}
public void CompletePermissionRequest(string runId, string toolName, string summary, bool granted)
{
var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName };
Complete(GetPermissionTaskKey(evt), summary, granted ? "completed" : "failed");
}
public void RecordHookResult(string runId, string toolName, string summary, bool success, string? filePath = null)
{
var evt = new Agent.AgentEvent { RunId = runId, ToolName = toolName };
StartOrUpdate(
GetHookTaskKey(evt),
"hook",
string.IsNullOrWhiteSpace(toolName) ? "hook" : $"{toolName} hook",
summary,
"running",
filePath);
Complete(GetHookTaskKey(evt), summary, success ? "completed" : "failed");
}
public void StartBackgroundRun(string id, string title, string summary)
{
var taskId = BuildBackgroundTaskId(id);
StartOrUpdate(taskId, "background", string.IsNullOrWhiteSpace(title) ? "background" : title, summary, "running");
}
public void CompleteBackgroundRun(string id, string title, string summary, bool success)
{
var taskId = BuildBackgroundTaskId(id);
if (!ActiveTasks.Any(x => string.Equals(x.Id, taskId, StringComparison.OrdinalIgnoreCase)))
{
StartOrUpdate(taskId, "background", string.IsNullOrWhiteSpace(title) ? "background" : title, summary, "running");
}
Complete(taskId, summary, success ? "completed" : "failed");
}
public void StartQueueRun(string tab, string id, string summary)
{
var taskId = BuildQueueTaskId(tab, id);
var title = string.IsNullOrWhiteSpace(tab) ? "queue" : $"{tab} queue";
StartOrUpdate(taskId, "queue", title, summary, "running");
}
public void CompleteQueueRun(string tab, string id, string summary, string status)
{
var taskId = BuildQueueTaskId(tab, id);
if (!ActiveTasks.Any(x => string.Equals(x.Id, taskId, StringComparison.OrdinalIgnoreCase)))
{
var title = string.IsNullOrWhiteSpace(tab) ? "queue" : $"{tab} queue";
StartOrUpdate(taskId, "queue", title, summary, "running");
}
Complete(taskId, summary, status);
}
public TaskSummaryState GetSummary()
{
var active = ActiveTasks;
return new TaskSummaryState
{
ActiveCount = active.Count,
PendingPermissionCount = active.Count(task =>
string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) ||
string.Equals(task.Status, "waiting", StringComparison.OrdinalIgnoreCase)),
LatestRecentTask = RecentTasks.FirstOrDefault(),
};
}
public void RestoreRecentFromExecutionEvents(IEnumerable<Models.ChatExecutionEvent>? history)
{
var restored = new List<TaskRunStore.TaskRun>();
var active = new Dictionary<string, TaskRunStore.TaskRun>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (history != null)
{
foreach (var item in history
.OrderBy(x => x.Timestamp)
.ThenBy(x => x.Iteration)
.ThenBy(x => x.ElapsedMs))
{
var scopedId = TryGetScopedId(item);
if (string.IsNullOrWhiteSpace(scopedId))
continue;
if (TryCreateActiveTaskFromExecutionEvent(item, out var activeTask))
{
active[scopedId] = activeTask;
continue;
}
if (IsTerminalExecutionEvent(item))
{
active.Remove(scopedId);
RemoveRunScopedActiveTasks(active, item);
}
}
foreach (var item in history
.OrderByDescending(x => x.Timestamp)
.ThenByDescending(GetReplayPriority)
.ThenByDescending(x => x.Iteration)
.ThenByDescending(x => x.ElapsedMs))
{
var task = TryCreateTaskFromExecutionEvent(item);
if (task == null || !seen.Add(task.Id))
continue;
restored.Add(task);
if (restored.Count >= 25)
break;
}
}
_store.RestoreState(active.Values, restored);
}
private static int GetReplayPriority(Models.ChatExecutionEvent evt)
{
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
return 100;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase))
return 90;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase))
return 80;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase))
return 70;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.HookResult), StringComparison.OrdinalIgnoreCase))
return evt.Success ? 60 : 65;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
return evt.Success ? 50 : 55;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase))
return 40;
if (string.Equals(evt.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase))
return 30;
return 10;
}
public void ApplyAgentEvent(Agent.AgentEvent evt)
{
switch (evt.Type)
{
case Agent.AgentEventType.PermissionRequest:
StartPermissionRequest(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? "권한 확인 대기" : evt.Summary,
evt.FilePath);
break;
case Agent.AgentEventType.PermissionGranted:
CompletePermissionRequest(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? "권한 확인됨" : evt.Summary,
true);
break;
case Agent.AgentEventType.PermissionDenied:
CompletePermissionRequest(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? "권한 거부됨" : evt.Summary,
false);
break;
case Agent.AgentEventType.ToolCall:
StartToolRun(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? $"{evt.ToolName} 실행 중" : evt.Summary,
evt.FilePath);
break;
case Agent.AgentEventType.ToolResult:
CompleteToolRun(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? evt.ToolName : evt.Summary,
evt.Success);
break;
case Agent.AgentEventType.SkillCall:
StartToolRun(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? $"{evt.ToolName} 실행 중" : evt.Summary,
evt.FilePath,
skill: true);
break;
case Agent.AgentEventType.HookResult:
RecordHookResult(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? "hook 실행" : evt.Summary,
evt.Success,
evt.FilePath);
break;
case Agent.AgentEventType.Thinking:
case Agent.AgentEventType.Planning:
StartAgentRun(evt.RunId, evt.Summary);
break;
case Agent.AgentEventType.Complete:
CompleteAgentRun(
evt.RunId,
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
true);
CompleteByPrefix(
string.IsNullOrWhiteSpace(evt.RunId) ? "tool:" : $"tool:{evt.RunId}:",
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
"completed");
CompleteByPrefix(
string.IsNullOrWhiteSpace(evt.RunId) ? "permission:" : $"permission:{evt.RunId}:",
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
"completed");
CompleteByPrefix(
string.IsNullOrWhiteSpace(evt.RunId) ? "hook:" : $"hook:{evt.RunId}:",
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
"completed");
break;
case Agent.AgentEventType.Error:
if (!string.IsNullOrWhiteSpace(evt.ToolName))
{
CompleteToolRun(
evt.RunId,
evt.ToolName,
string.IsNullOrWhiteSpace(evt.Summary) ? evt.ToolName : evt.Summary,
false);
}
else
{
CompleteAgentRun(
evt.RunId,
string.IsNullOrWhiteSpace(evt.Summary) ? "오류 발생" : evt.Summary,
false);
CompleteByPrefix(
string.IsNullOrWhiteSpace(evt.RunId) ? "permission:" : $"permission:{evt.RunId}:",
string.IsNullOrWhiteSpace(evt.Summary) ? "오류 발생" : evt.Summary,
"failed");
CompleteByPrefix(
string.IsNullOrWhiteSpace(evt.RunId) ? "hook:" : $"hook:{evt.RunId}:",
string.IsNullOrWhiteSpace(evt.Summary) ? "오류 발생" : evt.Summary,
"failed");
}
break;
}
}
public void ApplySubAgentStatus(Agent.SubAgentStatusEvent evt)
{
var taskId = string.IsNullOrWhiteSpace(evt.Id) ? "subagent:unknown" : $"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 Agent.SubAgentRunStatus.Started:
StartOrUpdate(taskId, "subagent", title, summary, "running");
StartBackgroundRun(evt.Id, title, summary);
break;
case Agent.SubAgentRunStatus.Completed:
Complete(taskId, summary, "completed");
CompleteBackgroundRun(evt.Id, title, summary, true);
break;
case Agent.SubAgentRunStatus.Failed:
Complete(taskId, summary, "failed");
CompleteBackgroundRun(evt.Id, title, summary, false);
break;
}
}
private static string GetAgentTaskKey(Agent.AgentEvent evt)
=> string.IsNullOrWhiteSpace(evt.RunId) ? "agent:main" : $"agent:{evt.RunId}";
private static string GetToolTaskKey(Agent.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(Agent.AgentEvent evt)
{
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName;
return string.IsNullOrWhiteSpace(evt.RunId)
? $"permission:{suffix}"
: $"permission:{evt.RunId}:{suffix}";
}
private static string GetHookTaskKey(Agent.AgentEvent evt)
{
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "unknown" : evt.ToolName;
return string.IsNullOrWhiteSpace(evt.RunId)
? $"hook:{suffix}"
: $"hook:{evt.RunId}:{suffix}";
}
private static string BuildBackgroundTaskId(string id)
=> string.IsNullOrWhiteSpace(id) ? "background:unknown" : $"background:{id}";
private static string BuildQueueTaskId(string tab, string id)
{
var scope = string.IsNullOrWhiteSpace(tab) ? "default" : tab;
var suffix = string.IsNullOrWhiteSpace(id) ? "unknown" : id;
return $"queue:{scope}:{suffix}";
}
private static TaskRunStore.TaskRun? TryCreateTaskFromExecutionEvent(Models.ChatExecutionEvent item)
{
static string BuildScopedId(string prefix, Models.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(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.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(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
}
if (string.Equals(item.Type, nameof(Agent.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(Agent.AgentEventType.HookResult), StringComparison.OrdinalIgnoreCase))
{
return new TaskRunStore.TaskRun
{
Id = BuildScopedId("hook", item),
Kind = "hook",
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "hook" : $"{item.ToolName} hook",
Summary = item.Summary,
Status = item.Success ? "completed" : "failed",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
}
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.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(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase) ? "failed" : "completed",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
}
return null;
}
private static bool TryCreateActiveTaskFromExecutionEvent(
Models.ChatExecutionEvent item,
out TaskRunStore.TaskRun task)
{
task = new TaskRunStore.TaskRun();
if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase))
{
task = new TaskRunStore.TaskRun
{
Id = BuildScopedId("permission", item),
Kind = "permission",
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "권한 요청" : $"{item.ToolName} 권한",
Summary = item.Summary,
Status = "waiting",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
return true;
}
if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase))
{
task = new TaskRunStore.TaskRun
{
Id = BuildScopedId("tool", item),
Kind = "tool",
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "tool" : item.ToolName,
Summary = item.Summary,
Status = "running",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
return true;
}
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase))
{
task = new TaskRunStore.TaskRun
{
Id = BuildScopedId("agent", item),
Kind = "agent",
Title = "main",
Summary = item.Summary,
Status = "running",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
return true;
}
return false;
}
private static bool IsTerminalExecutionEvent(Models.ChatExecutionEvent item)
{
if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static void RemoveRunScopedActiveTasks(
Dictionary<string, TaskRunStore.TaskRun> active,
Models.ChatExecutionEvent item)
{
if (string.IsNullOrWhiteSpace(item.RunId))
return;
var shouldClearAllByRun =
string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase) ||
(string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(item.ToolName));
if (!shouldClearAllByRun)
return;
var prefixes = new[]
{
$"agent:{item.RunId}:",
$"tool:{item.RunId}:",
$"permission:{item.RunId}:",
$"hook:{item.RunId}:",
};
var keys = active.Keys
.Where(key => prefixes.Any(prefix => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var key in keys)
active.Remove(key);
}
private static string? TryGetScopedId(Models.ChatExecutionEvent item)
{
if (string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionRequest), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionGranted), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.PermissionDenied), StringComparison.OrdinalIgnoreCase))
return BuildScopedId("permission", item);
if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
return BuildScopedId("tool", item);
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.Complete), StringComparison.OrdinalIgnoreCase))
return BuildScopedId("agent", item);
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Error), StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(item.ToolName))
return BuildScopedId("agent", item);
return BuildScopedId("tool", item);
}
return null;
}
private static string BuildScopedId(string prefix, Models.ChatExecutionEvent evt)
{
var suffix = string.IsNullOrWhiteSpace(evt.ToolName) ? "main" : evt.ToolName;
return string.IsNullOrWhiteSpace(evt.RunId)
? $"{prefix}:{suffix}"
: $"{prefix}:{evt.RunId}:{suffix}";
}
}