Files
sundynix-agentix/sundynix-gateway/internal/store/task_query.go
T
Blizzard e4ec74893f feat(admin): 任务观测支持下钻 —— 轨迹/输出/评测/DSL
全平台任务页此前只能看列表,点进去什么都没有。现在点一行开抽屉,四个页签:
执行轨迹、最终输出、评测明细、提交时的 DSL。不含审批操作——审批是客户端
用户的行为(桌面端 ApprovalBar),管理端只做观测。

不复用用户面的 /tasks/:id/replay:Task/Eval 都在租户插件作用域内,用请求 ctx
查别的租户的任务不会报错,而是静默返回空输出/空轨迹,UI 上表现为"这任务没
产出",比报错难查得多。新增 admin 端点走 WithoutTenant。

数据取自 sundynix_task 收尾落库的 output/trace 列,不依赖 Redis 流(10min TTL)。
所以这是复盘视图,运行中的任务轨迹为空——UI 里明确写出来,免得被当成轨迹丢了。

修的两处与测试环境失真有关(写测试时暴露的):
  - 测试库没配 NamingStrategy,与 OpenPostgres 不一致:多数模型有显式
    TableName() 碰巧对得上,但 Task 这类没有的会退化成 "tasks",导致写裸
    SQL 的查询在测试里查无此表。现已对齐 sundynix_ 前缀 + 单数表名。
  - graph 的 ::text 换成标准 cast(... as text):前者是 Postgres 专有,
    换掉后这条查询才能被内存库覆盖。

新增 5 组测试,其中一组专门先证明"租户过滤在测试环境里确实开着"——否则
"跨租户能读到"的断言可能只是因为插件没装,属于假过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:07:02 +08:00

133 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"time"
)
// 全平台任务观测(管理端「运行观测」页)。系统级口径:跨租户看所有任务,
// 故走 WithoutTenant 旁路租户插件 + raw Table 查询(同 RecentRuns 手动控制过滤)。
// AdminTaskRow 是管理端任务流一行:任务 + 租户名 + 提交人邮箱 + 评测分级(LEFT JOIN)。
type AdminTaskRow struct {
TaskID string `json:"task_id"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
Owner string `json:"owner"`
OwnerEmail string `json:"owner_email"`
Status string `json:"status"`
Detail string `json:"detail"`
Topic string `json:"topic"`
At time.Time `json:"at"`
EvalLevel string `json:"eval_level"`
EvalOverall float64 `json:"eval_overall"`
}
// AllTasks 全平台任务流(管理端观测;可按状态/租户过滤)。倒序,翻页。
// status 空=全部;tenantID 空=全租户。
func (p *Postgres) AllTasks(ctx context.Context, status, tenantID string, limit int) []AdminTaskRow {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
ctx = WithoutTenant(ctx) // 系统级:看所有租户的任务
q := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, " +
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, " +
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
Joins("left join sundynix_user u on u.id = t.owner").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.deleted_at is null")
if status != "" {
q = q.Where("t.status = ?", status)
}
if tenantID != "" {
q = q.Where("t.tenant_id = ?", tenantID)
}
var out []AdminTaskRow
q.Order("t.created_at desc").Limit(limit).Scan(&out)
return out
}
// TaskStatusCounts 全平台任务按状态计数(观测页的筛选标签 + 分布)。
func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
out := map[string]int64{}
if p.db == nil {
return out
}
ctx = WithoutTenant(ctx)
var rows []struct {
Status string
N int64
}
p.db.WithContext(ctx).Table("sundynix_task").
Select("status, count(*) as n").
Where("deleted_at is null").
Group("status").Scan(&rows)
for _, r := range rows {
out[r.Status] = r.N
}
return out
}
// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
type AdminTaskDetail struct {
AdminTaskRow
Graph string `json:"graph"` // 提交时的 DSL 原文
Output string `json:"output"` // 最终模型输出
Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
// gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
}
// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
type AdminEval struct {
Overall float64 `json:"overall"`
Rule float64 `json:"rule"`
LLM float64 `json:"llm"`
Faithful float64 `json:"faithful"`
Level string `json:"level"`
Flags string `json:"flags"`
Reason string `json:"reason"`
Sources int `json:"sources"`
Corrected bool `json:"corrected"`
}
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
// 必须 WithoutTenantTask/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
if p.db == nil || taskID == "" {
return nil
}
ctx = WithoutTenant(ctx)
var d AdminTaskDetail
err := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
"t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
Joins("left join sundynix_user u on u.id = t.owner").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.task_id = ? and t.deleted_at is null", taskID).
Scan(&d).Error
if err != nil || d.TaskID == "" {
return nil
}
var e Eval
if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
d.Eval = &AdminEval{
Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
}
}
return &d
}