backend: serve single-agent task templates with their status and results, rename the kinds in storage

This commit is contained in:
justme committed 2026-10-22 07:02:28 +08:00
1 parent e77880d372
commit 3a9ea46d6f
45 files changed
+763 -216

No files matched your search

@@ -32,6 +32,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<NodeInfoService>();
services.AddSingleton<LinkService>();
services.AddSingleton<LineService>();
services.AddSingleton<TargetService>();
services.AddSingleton<TestService>();
services.AddSingleton<IngestService>();
services.AddSingleton<WindowComputer>();
@@ -45,3 +45,12 @@ public sealed record CompareResult(
string Metric,
QueryRange Range,
SourcePlan Plan);
/// <summary>单个 Agent 任务(目标任务)的时序:和线路时序同一套分层与汇总,只是没有线路、事件和评分。</summary>
public sealed record TargetSeriesResult(
QueryRange Range,
SourcePlan Plan,
IReadOnlyList<long> T,
IReadOnlyDictionary<string, double?[]> Values,
SummaryResult Summary,
IReadOnlyDictionary<string, string> Versions);
@@ -138,6 +138,61 @@ public sealed class QueryService
return Result<SeriesResult>.Ok(new SeriesResult(line, p.Range, p.Plan, bucketed.T, values, summary, gaps, events, versions));
}
/// <summary>某个 Agent 上一个单个 Agent 任务的时序(序列 <c>target:&lt;任务 id&gt;</c>)。</summary>
public async ValueTask<Result<TargetSeriesResult>> TargetSeriesAsync(NodeId agent, TaskId task, RangeRequest request, MetricSelection selection, CancellationToken ct)
{
Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "3h");
if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error))
{
return Result<TargetSeriesResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, agent, TargetSeries(task), ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<TargetSeriesResult>.Fail(error);
}
BucketedRows bucketed = BucketedRows.Create(rows, p.Range);
int n = p.Range.Points;
var values = selection.ExportIds.ToDictionary(id => id, _ => new double?[n], StringComparer.Ordinal);
double widthSeconds = p.Plan.LevelWidthMs / 1000.0;
for (int i = 0; i < n; i++)
{
if (bucketed.Sets[i] is not { } set)
{
continue;
}
Dictionary<string, double> exported = ExportEvaluator.Evaluate(set, selection.ExportIds, bucketed.RowCounts[i] * widthSeconds);
foreach (string id in selection.ExportIds)
{
if (exported.TryGetValue(id, out double v))
{
values[id][i] = v;
}
}
}
var versions = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string id in selection.ExportIds)
{
if (MetricRegistry.TryFindExport(id, out MetricDef def, out _))
{
versions[id] = def.Fingerprint;
}
}
SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, new Dictionary<string, double>());
return Result<TargetSeriesResult>.Ok(new TargetSeriesResult(p.Range, p.Plan, bucketed.T, values, summary, versions));
}
/// <summary>这个单个 Agent 任务最后一个样本的时刻(Unix 毫秒);还没有数据返回 null。</summary>
public ValueTask<long?> TargetLatestAsync(NodeId agent, TaskId task, CancellationToken ct) =>
_loader.LatestSampleMsAsync(agent, TargetSeries(task), _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
private static string TargetSeries(TaskId task) => "target:" + task.Value;
/// <summary>这条线路最早的数据(Unix 毫秒);没有数据返回 null。</summary>
public ValueTask<long?> EarliestAsync(LineKey line, CancellationToken ct) =>
_loader.EarliestSampleMsAsync(line.Listener, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
@@ -0,0 +1,39 @@
using Fleet.Server;
namespace MoShiro.Backend.Application;
/// <summary>一个 Agent 上正在(或应该)运行的单个 Agent 任务:这个 Agent 的有效任务集里 <c>scope=target</c> 的那些。</summary>
public sealed record TargetView(AgentView Agent, EffectiveTask Task);
/// <summary>单个 Agent 任务目录:按 Agent 展开模板、覆盖和专属任务,列出每个 Agent 实际执行的目标。</summary>
public sealed class TargetService
{
private readonly AgentService _agents;
private readonly ITaskStore _tasks;
public TargetService(AgentService agents, ITaskStore tasks)
{
_agents = agents;
_tasks = tasks;
}
public async ValueTask<IReadOnlyList<TargetView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
{
IReadOnlyList<TaskRecord> all = await _tasks.ListAsync(ct);
var result = new List<TargetView>();
foreach (AgentView view in await _agents.ListAsync(ct))
{
if (view.Agent.Status != NodeStatus.Active || !include(view))
{
continue;
}
foreach (EffectiveTask task in EffectiveTasks.Compute(all, view.Agent, filterByAgentKinds: false).Where(t => t.Scope == EffectiveTasks.TargetScope))
{
result.Add(new TargetView(view, task));
}
}
return result;
}
}
@@ -13,7 +13,7 @@ public sealed record EffectiveTask(
ParamValues Resolved);
/// <summary>
/// 有效任务集:线路任务模板 + 针对单个 Agent 的覆盖 + 该 Agent 的目标任务。
/// 有效任务集:任务模板(线路任务、单个 Agent 的任务)+ 针对单个 Agent 的覆盖 + 只属于该 Agent 的目标任务。
/// 参数经 ParamSpec 校验并补全默认值,Backend 和节点使用同一套规则。
/// </summary>
public static class EffectiveTasks
@@ -35,23 +35,23 @@ public static class EffectiveTasks
foreach (TaskRecord task in all.OrderBy(t => t.Id.Value, StringComparer.Ordinal))
{
if (task.Scope == LineScope && task.AgentId is null)
if (task.AgentId is null)
{
// 模板:有覆盖时以覆盖为准(覆盖可以停用或改参数)
// 模板(线路任务或单个 Agent 的任务):有覆盖时以覆盖为准(覆盖可以停用、改参数,单个 Agent 的任务还可以换目标)
TaskRecord? over = overrides.GetValueOrDefault(task.Id);
if (over is not null)
{
if (over.Enabled)
{
Add(result, over.Id, task.Scope, over.Kind, null, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds);
Add(result, over.Id, task.Scope, over.Kind, over.Target ?? task.Target, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds);
}
}
else if (task.Enabled)
{
Add(result, task.Id, task.Scope, task.Kind, null, task.Params, task.Schedule, agent, filterByAgentKinds);
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
}
}
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.Enabled)
else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.BaseTaskId is null && task.Enabled)
{
Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds);
}
@@ -41,7 +41,7 @@ public sealed class TaskService
public ValueTask<TaskRecord?> GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct);
/// <summary>
/// 创建默认的线路任务模板,这样新 Agent 注册后自动获得任务:udp_echo、tcp_echo 持续运行,connect(TCP 建连和 TLS 握手耗时,
/// 创建默认的线路任务模板,这样新 Agent 注册后自动获得任务:quic_ping、tcp_ping 持续运行,connect(TCP 建连和 TLS 握手耗时,
/// 每分钟 6 次)也持续运行。每一批默认任务只种一次,用设置里的标记记住;你删掉的默认模板不会被重新建出来,
/// 后来新增的默认任务(connect)在已有部署升级后会补建一次。
/// </summary>
@@ -49,13 +49,13 @@ public sealed class TaskService
{
if (await _settings.GetAsync("tasks.seeded", ct) is null)
{
await SeedAsync([Kinds.UdpEcho, Kinds.TcpEcho], ct);
await SeedAsync([Kinds.QuicPing, Kinds.TcpPing], ct);
await _settings.SetAsync("tasks.seeded", "1", ct);
}
if (await _settings.GetAsync("tasks.seeded.connect", ct) is null)
{
await SeedAsync([Kinds.Connect], ct);
await SeedAsync([Kinds.TcpConnect], ct);
await _settings.SetAsync("tasks.seeded.connect", "1", ct);
}
}
@@ -223,9 +223,31 @@ public sealed class TaskService
if (scope == EffectiveTasks.TargetScope)
{
if (agentId is null || !TryNormalizeTarget(target, out string? normalized))
// 单个 Agent 的任务:没有 agent_id 是模板(所有 Agent 各自对这个目标执行),有 agent_id 是只属于这个 Agent 的任务,
// 或者(带 base_task_id)覆盖模板的参数、停用它、换一个目标
if (baseTaskId is { } templateId)
{
return ValidationError.Fail<ValidatedTask>("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port");
TaskRecord? template = await _tasks.GetAsync(templateId, ct);
if (agentId is null || template is null || template.AgentId is not null || template.Scope != EffectiveTasks.TargetScope)
{
return ValidationError.Fail<ValidatedTask>("base_task_id", "must reference a single-agent template and requires agent_id");
}
if (template.Kind != kind)
{
return ValidationError.Fail<ValidatedTask>("kind", "an override must use the same kind as its template");
}
if (target is null)
{
return Result<ValidatedTask>.Ok(new ValidatedTask(null));
}
}
string? normalized = TargetHost.Normalize(kind, target);
if (normalized is null)
{
return ValidationError.Fail<ValidatedTask>("target", kind == TargetHost.HttpKind ? "must be an http:// or https:// URL" : "must be a host name or IP address with an optional port");
}
return Result<ValidatedTask>.Ok(new ValidatedTask(normalized));
@@ -288,28 +310,4 @@ public sealed class TaskService
return Result<bool>.Ok(true);
}
/// <summary>只接受 IP 地址(可带端口),不做 DNS 解析,避免把 Agent 用作解析器。</summary>
public static bool TryNormalizeTarget(string? target, out string? normalized)
{
normalized = null;
if (string.IsNullOrWhiteSpace(target))
{
return false;
}
if (IPAddress.TryParse(target, out IPAddress? ip))
{
normalized = ip.ToString();
return true;
}
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint) && endpoint.Port is > 0 and <= 65535)
{
normalized = endpoint.ToString();
return true;
}
return false;
}
}
@@ -18,7 +18,7 @@ public static class Plans
private static JsonElement S(string value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.String);
private static PhaseSpec Train(double mbps) =>
new("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10);
new("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10);
public static readonly PlanSpec Hy2Ladder = new(
"hy2_ladder",
@@ -31,9 +31,9 @@ public static class Plans
"REALITY session",
"Connection establishment, a short (4 s) bulk transfer for the peak throughput and a held connection.",
[
new PhaseSpec("connect", new Dictionary<string, JsonElement> { ["per_minute"] = N(20) }, 30),
new PhaseSpec("tcp_bulk", new Dictionary<string, JsonElement> { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4),
new PhaseSpec("hold", new Dictionary<string, JsonElement> { ["heartbeat_s"] = N(5) }, 30),
new PhaseSpec("tcp_connect", new Dictionary<string, JsonElement> { ["per_minute"] = N(20) }, 30),
new PhaseSpec("tcp_transfer", new Dictionary<string, JsonElement> { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4),
new PhaseSpec("tls_hold", new Dictionary<string, JsonElement> { ["heartbeat_s"] = N(5) }, 30),
]);
public static readonly IReadOnlyList<PlanSpec> All = [Hy2Ladder, RealitySession];