You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

283 lines
12 KiB

package controlplane
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// maxSkillContentBytes bounds one Skill Markdown document. The Agent applies the
// same limit so content is never silently truncated after deployment.
const maxSkillContentBytes = 50000
// Skill is a Markdown-only capability guide owned by the controller.
type Skill struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Checksum string `json:"checksum"`
UpdatedAt string `json:"updatedAt"`
}
type SkillInput struct {
Name string `json:"name"`
Version string `json:"version"`
Content string `json:"content"`
}
// SkillDocument is the safe, read-only Markdown view exposed to the local UI.
// It intentionally contains no write capability or executable entry point.
type SkillDocument struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Content string `json:"content"`
UpdatedAt string `json:"updatedAt"`
}
type AgentSkillStatus struct {
AgentID string `json:"agentID"`
AgentName string `json:"agentName"`
SkillID string `json:"skillID"`
SkillName string `json:"skillName"`
Version string `json:"version"`
ApplyStatus string `json:"applyStatus"`
LastError string `json:"lastError"`
UpdatedAt string `json:"updatedAt"`
}
func (c *ControlPlane) Skills() ([]Skill, error) {
rows, err := c.db.Query("SELECT skill_id, name, version, checksum, updated_at FROM skill_packages ORDER BY name")
if err != nil {
return nil, fmt.Errorf("无法读取 Skills: %w", err)
}
defer rows.Close()
skills := make([]Skill, 0)
for rows.Next() {
var skill Skill
if err := rows.Scan(&skill.ID, &skill.Name, &skill.Version, &skill.Checksum, &skill.UpdatedAt); err != nil {
return nil, err
}
skills = append(skills, skill)
}
return skills, rows.Err()
}
// SkillDocument returns one controller-owned Markdown file for read-only preview.
// Credentials embedded in legacy reference material are redacted before leaving
// the controller process.
func (c *ControlPlane) SkillDocument(skillID string) (SkillDocument, error) {
if !isSkillID(skillID) {
return SkillDocument{}, errors.New("Skill 标识无效")
}
var document SkillDocument
err := c.db.QueryRow("SELECT skill_id, name, version, content, updated_at FROM skill_packages WHERE skill_id = ?", skillID).Scan(
&document.ID, &document.Name, &document.Version, &document.Content, &document.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return SkillDocument{}, errors.New("未找到 Skill Markdown 文件")
}
if err != nil {
return SkillDocument{}, fmt.Errorf("无法读取 Skill Markdown 文件: %w", err)
}
document.Content = redactSkillMarkdown(document.Content)
return document, nil
}
// SaveSkill stores one Markdown-only Skill package. A skill is identified by its
// display name, so importing an updated version keeps the identity — and therefore
// every existing Agent assignment — intact instead of creating a duplicate.
func (c *ControlPlane) SaveSkill(input SkillInput) (Skill, error) {
name, version, content := strings.TrimSpace(input.Name), strings.TrimSpace(input.Version), strings.TrimSpace(input.Content)
if name == "" || version == "" || content == "" {
return Skill{}, errors.New("请完整填写 Skill 名称、版本和内容")
}
if len([]rune(name)) > 80 || len([]rune(version)) > 40 || len(content) > maxSkillContentBytes {
return Skill{}, errors.New("Skill 内容或名称过长")
}
sum := sha256.Sum256([]byte(content))
checksum := hex.EncodeToString(sum[:])
now := time.Now().UTC().Format(time.RFC3339Nano)
skillID := ""
err := c.db.QueryRow("SELECT skill_id FROM skill_packages WHERE name = ?", name).Scan(&skillID)
switch {
case errors.Is(err, sql.ErrNoRows):
skillID, err = randomHex(16)
if err != nil {
return Skill{}, err
}
if _, err = c.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES (?, ?, ?, ?, ?, ?)", skillID, name, version, content, checksum, now); err != nil {
return Skill{}, fmt.Errorf("无法保存 Skill: %w", err)
}
c.recordAudit("skill.package_saved", "controller", "skill:"+skillID, "completed", "已保存 Skill 包。")
case err != nil:
return Skill{}, fmt.Errorf("无法读取 Skill: %w", err)
default:
if _, err = c.db.Exec("UPDATE skill_packages SET version = ?, content = ?, checksum = ?, updated_at = ? WHERE skill_id = ?", version, content, checksum, now, skillID); err != nil {
return Skill{}, fmt.Errorf("无法保存 Skill: %w", err)
}
c.recordAudit("skill.package_updated", "controller", "skill:"+skillID, "completed", "已按名称覆盖更新 Skill 包内容与版本。")
}
c.redeploySkill(skillID)
return Skill{ID: skillID, Name: name, Version: version, Checksum: checksum, UpdatedAt: now}, nil
}
// redeploySkill re-sends an updated Skill to the Agents that already selected it.
func (c *ControlPlane) redeploySkill(skillID string) {
rows, err := c.db.Query("SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?", skillID)
if err != nil {
return
}
agentIDs := make([]string, 0)
for rows.Next() {
var agentID string
if rows.Scan(&agentID) == nil {
agentIDs = append(agentIDs, agentID)
}
}
rows.Close()
for _, agentID := range agentIDs {
if _, err := c.db.Exec("UPDATE agent_skill_assignments SET apply_status = 'pending', last_error = '', updated_at = ? WHERE agent_id = ? AND skill_id = ?", time.Now().UTC().Format(time.RFC3339Nano), agentID, skillID); err != nil {
continue
}
go c.pushSkills(agentID)
}
}
func (c *ControlPlane) DeploySkill(skillID string, agentIDs []string) error {
if skillID == "" || len(agentIDs) == 0 {
return errors.New("请选择 Skill 和至少一个 Agent")
}
var exists int
if err := c.db.QueryRow("SELECT COUNT(*) FROM skill_packages WHERE skill_id = ?", skillID).Scan(&exists); err != nil || exists != 1 {
return errors.New("未找到要下发的 Skill")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, agentID := range agentIDs {
if _, err := c.db.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES (?, ?, 'pending', '', ?) ON CONFLICT(agent_id, skill_id) DO UPDATE SET apply_status = 'pending', last_error = '', updated_at = excluded.updated_at", agentID, skillID, now); err != nil {
return fmt.Errorf("无法保存 Skill 下发任务: %w", err)
}
c.recordAudit("skill.deployment_requested", "controller", "agent:"+agentID, "pending", "已请求下发一个已批准 Skill。")
c.pushSkills(agentID)
}
return nil
}
// SetAgentSkills atomically replaces one Agent's selected controller-approved Skills.
func (c *ControlPlane) SetAgentSkills(agentID string, skillIDs []string) error {
if strings.TrimSpace(agentID) == "" {
return errors.New("Agent 标识无效")
}
var agentExists int
if err := c.db.QueryRow("SELECT COUNT(*) FROM managed_agents WHERE agent_id = ?", agentID).Scan(&agentExists); err != nil || agentExists != 1 {
return errors.New("目标 Agent 不存在")
}
unique := make(map[string]struct{}, len(skillIDs))
for _, skillID := range skillIDs {
if _, seen := unique[skillID]; seen || skillID == "" {
continue
}
var exists int
if err := c.db.QueryRow("SELECT COUNT(*) FROM skill_packages WHERE skill_id = ?", skillID).Scan(&exists); err != nil || exists != 1 {
return errors.New("选择了不存在的 Skill")
}
unique[skillID] = struct{}{}
}
transaction, err := c.db.Begin()
if err != nil {
return err
}
defer transaction.Rollback()
if _, err = transaction.Exec("DELETE FROM agent_skill_assignments WHERE agent_id = ?", agentID); err != nil {
return fmt.Errorf("无法更新 Agent Skill 选择: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err = transaction.Exec("INSERT INTO agent_skill_selections(agent_id, updated_at) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET updated_at = excluded.updated_at", agentID, now); err != nil {
return fmt.Errorf("无法保存 Agent Skill 选择: %w", err)
}
for skillID := range unique {
if _, err = transaction.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES (?, ?, 'pending', '', ?)", agentID, skillID, now); err != nil {
return fmt.Errorf("无法保存 Agent Skill 选择: %w", err)
}
}
if err = transaction.Commit(); err != nil {
return err
}
c.recordAudit("skill.selection_updated", "controller", "agent:"+agentID, "pending", fmt.Sprintf("已更新为 %d 个已批准 Skill。", len(unique)))
c.pushSkills(agentID)
return nil
}
func (c *ControlPlane) AgentSkillStatuses() ([]AgentSkillStatus, error) {
rows, err := c.db.Query("SELECT a.agent_id, a.name, s.skill_id, s.name, s.version, x.apply_status, x.last_error, x.updated_at FROM agent_skill_assignments x JOIN managed_agents a ON a.agent_id=x.agent_id JOIN skill_packages s ON s.skill_id=x.skill_id ORDER BY a.name, s.name")
if err != nil {
return nil, fmt.Errorf("无法读取 Skill 状态: %w", err)
}
defer rows.Close()
result := make([]AgentSkillStatus, 0)
for rows.Next() {
var status AgentSkillStatus
if err := rows.Scan(&status.AgentID, &status.AgentName, &status.SkillID, &status.SkillName, &status.Version, &status.ApplyStatus, &status.LastError, &status.UpdatedAt); err != nil {
return nil, err
}
result = append(result, status)
}
return result, rows.Err()
}
func (c *ControlPlane) pushSkills(agentID string) {
client := c.clientFor(agentID)
if client == nil {
return
}
rows, err := c.db.Query("SELECT s.skill_id, s.name, s.version, s.content, s.checksum FROM agent_skill_assignments x JOIN skill_packages s ON s.skill_id=x.skill_id WHERE x.agent_id=?", agentID)
if err != nil {
return
}
defer rows.Close()
skills := make([]map[string]string, 0)
for rows.Next() {
var id, name, version, content, checksum string
if rows.Scan(&id, &name, &version, &content, &checksum) != nil {
return
}
skills = append(skills, map[string]string{"id": id, "name": name, "version": version, "content": content, "checksum": checksum})
}
if len(skills) == 0 {
var selectionCount int
if c.db.QueryRow("SELECT COUNT(*) FROM agent_skill_selections WHERE agent_id = ?", agentID).Scan(&selectionCount) != nil || selectionCount == 0 {
return
}
}
if err := client.send(map[string]any{"type": "skill_snapshot", "schema_version": protocolVersion, "skills": skills}); err == nil {
_, _ = c.db.Exec("UPDATE agent_skill_assignments SET apply_status='sent', updated_at=? WHERE agent_id=? AND apply_status='pending'", time.Now().UTC().Format(time.RFC3339Nano), agentID)
c.recordAudit("skill.snapshot_sent", "controller", "agent:"+agentID, "sent", fmt.Sprintf("已下发 %d 个已选择 Skill。", len(skills)))
}
}
func (c *ControlPlane) applySkillAck(agentID string, message map[string]any) {
skillID, _ := message["skill_id"].(string)
status, _ := message["status"].(string)
errorText, _ := message["error"].(string)
if skillID == "" || (status != "applied" && status != "failed") {
return
}
_, _ = c.db.Exec("UPDATE agent_skill_assignments SET apply_status=?, last_error=?, updated_at=? WHERE agent_id=? AND skill_id=?", status, limit(sanitizeDisplayText(errorText), 1000), time.Now().UTC().Format(time.RFC3339Nano), agentID, skillID)
c.recordAudit("skill.deployment_ack", "agent:"+agentID, "skill:"+skillID, status, errorText)
}
func isSkillID(value string) bool {
return len(value) == 32 && regexp.MustCompile(`^[a-f0-9]{32}$`).MatchString(value)
}
var skillSecretLinePattern = regexp.MustCompile(`(?im)^(\s*(?:api[_ -]?key|password|passwd|secret|token|credential|authorization|用户名|账号|密码|密钥)\s*[:=]\s*).*$`)
func redactSkillMarkdown(content string) string {
content = sanitizeDisplayText(content)
return skillSecretLinePattern.ReplaceAllString(content, `${1}[已脱敏]`)
}