Merge pull request #48 from xmindlab-heicode/feat/agent-preflight-checklist
feat(preflight): read-only preflight checklist + execution summary (#39, #40)
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Preflight / execution-summary (#29 EPIC, sub-issues #39 缺失项检测 + #40 可读执行摘要).
|
||||
//
|
||||
// Manager 是辅助控制台,不是编码入口:用户在启动 agent 前应看到一份「执行摘要」——
|
||||
// 这个 agent 会用哪些资源、还缺什么、有哪些高危操作、预算上限是多少 —— 只确认摘要而非
|
||||
// 面对完整参数(产品文档「准备清单 / 推荐摘要」第四、五步)。
|
||||
//
|
||||
// 本文件实现只读的 preflight:GET /api/heicode/preflight?template_id=&binding_ids=1,2,3
|
||||
// 返回缺失项 + 可读摘要。#41(confirm + 审计 + 防篡改版本校验)在此之上单独实现。
|
||||
//
|
||||
// 红线(#40):resource 视图绝不暴露 secret_ref / channelId / base_url / price 等敏感字段;
|
||||
// 高危操作用固定 enum,不自由文本。
|
||||
|
||||
// 高危操作固定 enum(#40 红线):只能取以下值。
|
||||
const (
|
||||
highRiskProductionDeploy = "production_deploy" // 生产部署 / 代码改动推送
|
||||
highRiskDBWrite = "db_write" // 数据库写入
|
||||
highRiskCloudDelete = "cloud_resource_delete" // 云资源删除
|
||||
highRiskProductionSecret = "production_secret" // 生产密钥访问
|
||||
highRiskLargeBudget = "large_budget" // 大额预算消耗
|
||||
)
|
||||
|
||||
var highRiskOpLabels = map[string]string{
|
||||
highRiskProductionDeploy: "生产部署 / 代码改动",
|
||||
highRiskDBWrite: "数据库写入",
|
||||
highRiskCloudDelete: "云资源删除",
|
||||
highRiskProductionSecret: "生产密钥访问",
|
||||
highRiskLargeBudget: "大额预算消耗",
|
||||
}
|
||||
|
||||
// 准备清单要求用户连接的资源类别(#39 缺失项检测)。budget 单独判定。
|
||||
var preflightRequiredKinds = []struct {
|
||||
kind string // 与 ResourceBinding.ResourceType 对齐
|
||||
label string
|
||||
}{
|
||||
{"git", "代码仓库(Git)"},
|
||||
{"sk", "SK 资源包"},
|
||||
{"project_document", "项目文档"},
|
||||
{"cloud_account", "云账号"},
|
||||
}
|
||||
|
||||
type preflightRole struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// preflightResource 是资源的**脱敏**视图:绝不含 secret_ref/channelId/base_url/price。
|
||||
type preflightResource struct {
|
||||
BindingID int `json:"binding_id"`
|
||||
Type string `json:"type"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
HasSecret bool `json:"has_secret"` // 是否已绑定凭证(布尔,不含凭证本身)
|
||||
}
|
||||
|
||||
type preflightMissing struct {
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type preflightHighRisk struct {
|
||||
Op string `json:"op"` // 固定 enum
|
||||
Label string `json:"label"` // 中文展示
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
}
|
||||
|
||||
type preflightBudget struct {
|
||||
RemainingQuota int64 `json:"remaining_quota"`
|
||||
QuotaPerUnit float64 `json:"quota_per_unit"`
|
||||
TierMaxAgents int `json:"tier_max_agents"`
|
||||
CurrentAgents int `json:"current_agents"`
|
||||
}
|
||||
|
||||
type preflightSummary struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
AgentRole preflightRole `json:"agent_role"`
|
||||
Resources []preflightResource `json:"resources"`
|
||||
InvalidBindings []int `json:"invalid_bindings"` // 请求里无效/非本人/非 active 的绑定 id
|
||||
Missing []preflightMissing `json:"missing"`
|
||||
HighRiskOps []preflightHighRisk `json:"high_risk_ops"`
|
||||
Budget preflightBudget `json:"budget"`
|
||||
ApprovalPolicy gin.H `json:"approval_policy"`
|
||||
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
|
||||
}
|
||||
|
||||
// computePreflight 是纯函数(无 DB / 无 gin.Context),便于单测。给定模板、已解析的脱敏
|
||||
// 资源视图、用户剩余额度、tier 上限与当前在跑 agent 数,产出执行摘要。
|
||||
func computePreflight(tpl model.AgentTemplate, resources []preflightResource, invalidBindings []int,
|
||||
remainingQuota int64, quotaPerUnit float64, maxAgents, currentAgents int) preflightSummary {
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, r := range resources {
|
||||
present[r.Type] = true
|
||||
}
|
||||
|
||||
// #39 缺失项:必需资源类别未绑定 + 预算不足。
|
||||
missing := make([]preflightMissing, 0)
|
||||
for _, req := range preflightRequiredKinds {
|
||||
if !present[req.kind] {
|
||||
missing = append(missing, preflightMissing{Kind: req.kind, Reason: "未绑定" + req.label})
|
||||
}
|
||||
}
|
||||
budgetInsufficient := remainingQuota <= 0
|
||||
if budgetInsufficient {
|
||||
missing = append(missing, preflightMissing{Kind: "budget", Reason: "账户可用额度不足,请充值或开通订阅"})
|
||||
}
|
||||
agentSlotFull := maxAgents > 0 && currentAgents >= maxAgents
|
||||
if agentSlotFull {
|
||||
missing = append(missing, preflightMissing{Kind: "agent_slot", Reason: "在跑 Agent 数已达上限(" + strconv.Itoa(maxAgents) + "),请先停止/删除一个"})
|
||||
}
|
||||
|
||||
// #40 高危操作(固定 enum):由已绑资源类型推导,均需审批。
|
||||
highRisk := make([]preflightHighRisk, 0)
|
||||
addRisk := func(op string) {
|
||||
highRisk = append(highRisk, preflightHighRisk{Op: op, Label: highRiskOpLabels[op], RequiresApproval: true})
|
||||
}
|
||||
if present["git"] {
|
||||
addRisk(highRiskProductionDeploy)
|
||||
}
|
||||
if present["database"] {
|
||||
addRisk(highRiskDBWrite)
|
||||
}
|
||||
if present["cloud_account"] || present["cloud_resource"] {
|
||||
addRisk(highRiskCloudDelete)
|
||||
addRisk(highRiskProductionSecret)
|
||||
}
|
||||
// 预算是标准确认项:启动前用户须确认本任务的预算口径。
|
||||
addRisk(highRiskLargeBudget)
|
||||
|
||||
return preflightSummary{
|
||||
TemplateID: tpl.TemplateKey,
|
||||
AgentRole: preflightRole{
|
||||
TemplateID: tpl.TemplateKey,
|
||||
Name: tpl.NameZh,
|
||||
Model: tpl.Model,
|
||||
},
|
||||
Resources: resources,
|
||||
InvalidBindings: invalidBindings,
|
||||
Missing: missing,
|
||||
HighRiskOps: highRisk,
|
||||
Budget: preflightBudget{
|
||||
RemainingQuota: remainingQuota,
|
||||
QuotaPerUnit: quotaPerUnit,
|
||||
TierMaxAgents: maxAgents,
|
||||
CurrentAgents: currentAgents,
|
||||
},
|
||||
ApprovalPolicy: gin.H{"mode": "per_high_risk_op"},
|
||||
Ready: len(missing) == 0,
|
||||
}
|
||||
}
|
||||
|
||||
// parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。
|
||||
func parsePreflightBindingIDs(c *gin.Context) []int {
|
||||
raw := c.QueryArray("binding_ids")
|
||||
if len(raw) == 1 && strings.Contains(raw[0], ",") {
|
||||
raw = strings.Split(raw[0], ",")
|
||||
}
|
||||
ids := make([]int, 0, len(raw))
|
||||
seen := map[int]bool{}
|
||||
for _, s := range raw {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 && !seen[n] {
|
||||
seen[n] = true
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40).
|
||||
func HeicodePreflight(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
||||
return
|
||||
}
|
||||
if model.DB == nil {
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
|
||||
return
|
||||
}
|
||||
templateID := strings.TrimSpace(c.Query("template_id"))
|
||||
if templateID == "" {
|
||||
agentError(c, "POLICY_REJECTED", "template_id is required")
|
||||
return
|
||||
}
|
||||
tpl, ok := loadAgentTemplate(templateID)
|
||||
if !ok {
|
||||
agentError(c, "POLICY_REJECTED", "unknown template_id")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求的绑定 → 脱敏视图;无效/非本人/非 active 的归入 invalidBindings。
|
||||
bindingIDs := parsePreflightBindingIDs(c)
|
||||
resources := make([]preflightResource, 0, len(bindingIDs))
|
||||
invalid := make([]int, 0)
|
||||
for _, id := range bindingIDs {
|
||||
var b model.ResourceBinding
|
||||
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&b).Error; err != nil {
|
||||
invalid = append(invalid, id)
|
||||
continue
|
||||
}
|
||||
resources = append(resources, preflightResource{
|
||||
BindingID: b.Id,
|
||||
Type: b.ResourceType,
|
||||
Provider: b.Provider,
|
||||
Name: b.Name,
|
||||
Status: b.Status,
|
||||
HasSecret: strings.TrimSpace(b.SecretRef) != "",
|
||||
})
|
||||
}
|
||||
|
||||
// 用户剩余额度 + tier 上限 + 当前在跑 agent 数(与部署门禁同口径)。
|
||||
var remainingQuota int64
|
||||
if u, err := model.GetUserById(userID, false); err == nil && u != nil {
|
||||
remainingQuota = int64(u.Quota)
|
||||
}
|
||||
maxAgents := model.GetUserMaxAgents(userID, common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5))
|
||||
var currentAgents int64
|
||||
_ = model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
||||
Count(¤tAgents).Error
|
||||
|
||||
summary := computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents))
|
||||
common.ApiSuccess(c, summary)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func tpl() model.AgentTemplate {
|
||||
return model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus"}
|
||||
}
|
||||
|
||||
// #39: 全部必需类别缺失 + 预算不足 → missing 覆盖各项,ready=false。
|
||||
func TestComputePreflight_AllMissing(t *testing.T) {
|
||||
s := computePreflight(tpl(), nil, nil, 0, 500000, 5, 0)
|
||||
kinds := map[string]bool{}
|
||||
for _, m := range s.Missing {
|
||||
kinds[m.Kind] = true
|
||||
}
|
||||
require.True(t, kinds["git"])
|
||||
require.True(t, kinds["sk"])
|
||||
require.True(t, kinds["project_document"])
|
||||
require.True(t, kinds["cloud_account"])
|
||||
require.True(t, kinds["budget"], "余额为 0 应报 budget 缺失")
|
||||
require.False(t, kinds["agent_slot"], "0/5 未满,不应报 agent_slot")
|
||||
require.False(t, s.Ready)
|
||||
}
|
||||
|
||||
// #39: 全部齐备 + 有余额 + 槽位未满 → ready=true。
|
||||
func TestComputePreflight_Ready(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
{BindingID: 2, Type: "sk", Provider: "custom", Name: "sk-pack", Status: "active", HasSecret: true},
|
||||
{BindingID: 3, Type: "project_document", Provider: "custom", Name: "doc", Status: "active"},
|
||||
{BindingID: 4, Type: "cloud_account", Provider: "azure", Name: "sub", Status: "active", HasSecret: true},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1)
|
||||
require.Empty(t, s.Missing)
|
||||
require.True(t, s.Ready)
|
||||
}
|
||||
|
||||
// #39: agent 槽位已满 → ready=false + agent_slot 缺失项。
|
||||
func TestComputePreflight_AgentSlotFull(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git"}, {BindingID: 2, Type: "sk"},
|
||||
{BindingID: 3, Type: "project_document"}, {BindingID: 4, Type: "cloud_account"},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 5)
|
||||
require.False(t, s.Ready)
|
||||
found := false
|
||||
for _, m := range s.Missing {
|
||||
if m.Kind == "agent_slot" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
// #40: 高危操作只用固定 enum,并按已绑资源类型推导。
|
||||
func TestComputePreflight_HighRiskEnum(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git"},
|
||||
{BindingID: 2, Type: "database"},
|
||||
{BindingID: 3, Type: "cloud_account"},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 0)
|
||||
ops := map[string]bool{}
|
||||
for _, h := range s.HighRiskOps {
|
||||
require.Contains(t, highRiskOpLabels, h.Op, "high-risk op 必须是固定 enum")
|
||||
require.True(t, h.RequiresApproval)
|
||||
ops[h.Op] = true
|
||||
}
|
||||
require.True(t, ops[highRiskProductionDeploy]) // git
|
||||
require.True(t, ops[highRiskDBWrite]) // database
|
||||
require.True(t, ops[highRiskCloudDelete]) // cloud_account
|
||||
require.True(t, ops[highRiskProductionSecret]) // cloud_account
|
||||
require.True(t, ops[highRiskLargeBudget]) // 标准确认项
|
||||
}
|
||||
|
||||
// #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。
|
||||
func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
}
|
||||
s := computePreflight(tpl(), res, []int{99}, 1_000_000, 500000, 5, 0)
|
||||
b, err := json.Marshal(s)
|
||||
require.NoError(t, err)
|
||||
out := string(b)
|
||||
for _, banned := range []string{"secret_ref", "channel_id", "channelId", "base_url", "baseUrl", "price"} {
|
||||
require.NotContains(t, out, banned, "执行摘要不得暴露敏感字段: "+banned)
|
||||
}
|
||||
require.Contains(t, out, "\"has_secret\":true") // 只暴露布尔
|
||||
require.Contains(t, out, "\"invalid_bindings\":[99]")
|
||||
}
|
||||
@@ -535,6 +535,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates)
|
||||
heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels)
|
||||
// Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要).
|
||||
heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight)
|
||||
heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent)
|
||||
heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents)
|
||||
heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)
|
||||
|
||||
Reference in New Issue
Block a user