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
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}[已脱敏]`)
|
|
}
|
|
|