using System.Text.Json; namespace Zcbot.WindowsNode; internal interface IJobMonitorService { IReadOnlyList ReadSnapshots(int limit = 50); } internal sealed class JobMonitorService(string jobsDirectory) : IJobMonitorService { public IReadOnlyList ReadSnapshots(int limit = 50) { if (!Directory.Exists(jobsDirectory)) { return []; } var snapshots = new List(); foreach (var requestPath in Directory.EnumerateFiles( jobsDirectory, "request.json", SearchOption.AllDirectories)) { try { using var requestDocument = JsonDocument.Parse(File.ReadAllBytes(requestPath)); var root = requestDocument.RootElement; if (!JobInboxStore.TryReadGuid(root, "job_id", out var jobId)) { continue; } var jobDirectory = Directory.GetParent( Directory.GetParent(requestPath)!.FullName)!.FullName; var acceptedAt = ReadDate(root, "accepted_at") ?? new DateTimeOffset(File.GetCreationTimeUtc(requestPath)); var capability = ReadString(root, "capability", "unknown"); var title = ReadDisplayTitle(root); var inputFilename = root.TryGetProperty("input_transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array ? string.Join(", ", transfers.EnumerateArray() .Select(item => ReadString(item, "filename", "-"))) : "-"; var state = JobInboxStore.ReadState(Path.Combine(jobDirectory, "state.json")); var terminal = JobInboxStore.ReadTerminal(Path.Combine(jobDirectory, "terminal.json")); var uploadPath = Path.Combine(jobDirectory, "upload-complete.json"); var uploadComplete = File.Exists(uploadPath); var cloudTerminalPath = Path.Combine(jobDirectory, "cloud-terminal.json"); var cloudTerminal = JobInboxStore.ReadTerminal(cloudTerminalPath); var stage = state?.Stage ?? "accepted"; var progress = state?.Progress ?? 0; var detail = state?.Detail ?? "任务已由本机接收"; var updatedAt = state is not null && state.UpdatedAt != DateTimeOffset.MinValue && state.UpdatedAt > acceptedAt ? state.UpdatedAt : acceptedAt; if (terminal is JsonElement terminalValue) { var terminalStatus = ReadString(terminalValue, "status", "failed"); if (terminalStatus == "succeeded" && !uploadComplete) { stage = "uploading_outputs"; progress = Math.Max(progress, 90); detail = state?.Stage == "uploading_outputs" ? state.Detail : "软件执行完成,等待上传结果"; } else { stage = terminalStatus; progress = terminalStatus == "succeeded" ? 100 : progress; detail = TerminalDetail(terminalValue, terminalStatus, detail); } updatedAt = LatestWrite(updatedAt, Path.Combine(jobDirectory, "terminal.json")); } if (uploadComplete) { stage = "succeeded"; progress = 100; detail = File.Exists(Path.Combine(jobDirectory, "workspace-result.json")) ? "预览已上传,工程保存在本机工作区" : "结果已上传并由云端确认"; updatedAt = LatestWrite(updatedAt, uploadPath); } else if (cloudTerminal is JsonElement cloudValue) { var cloudStatus = ReadString(cloudValue, "status", "failed"); stage = "cloud_terminal"; progress = 100; detail = cloudStatus == "cancelled" ? "云端任务已取消,本地生成的结果仍保留" : "云端任务已终止,本地生成的结果仍保留"; updatedAt = LatestWrite(updatedAt, cloudTerminalPath); } snapshots.Add(new JobDisplaySnapshot( jobId, capability, title, inputFilename, stage, Math.Clamp(progress, 0, 100), detail, acceptedAt, updatedAt, uploadComplete)); } catch (Exception exception) when ( exception is JsonException or IOException or UnauthorizedAccessException) { } } return snapshots .OrderByDescending(item => item.IsActive) .ThenByDescending(item => item.UpdatedAt) .Take(Math.Max(1, limit)) .ToArray(); } private static string TerminalDetail(JsonElement terminal, string status, string fallback) { if (terminal.TryGetProperty("error", out var error)) { var detail = ReadString(error, "detail", ""); if (!string.IsNullOrWhiteSpace(detail)) { return detail; } } return status switch { "succeeded" => "软件任务执行成功", "cancelled" => "任务已取消", "failed" => "任务执行失败", _ => fallback, }; } private static DateTimeOffset LatestWrite(DateTimeOffset current, string path) { var writtenAt = new DateTimeOffset(File.GetLastWriteTimeUtc(path)); return writtenAt > current ? writtenAt : current; } private static string ReadString(JsonElement value, string name, string fallback) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString()) ? property.GetString()! : fallback; private static string ReadDisplayTitle(JsonElement root) { const string fallback = "未命名任务"; if (root.TryGetProperty("request_summary", out var summary) && summary.ValueKind == JsonValueKind.Object) { var title = ReadString(summary, "title", fallback); if (title != fallback) { return title; } } if (root.TryGetProperty("request", out var request) && request.ValueKind == JsonValueKind.Object && request.TryGetProperty("operation", out var operation) && operation.ValueKind == JsonValueKind.Object) { foreach (var action in operation.EnumerateObject()) { if (action.Value.ValueKind != JsonValueKind.Object) { continue; } var title = ReadString(action.Value, "title", fallback); if (title != fallback) { return title; } } } return fallback; } private static DateTimeOffset? ReadDate(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(property.GetString(), out var parsed) ? parsed : null; }