chore: initial commit (sources + docs, ignore build artifacts and secrets)
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Skill is a safe 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
|
||||
}
|
||||
|
||||
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) > 50000 {
|
||||
return Skill{}, errors.New("Skill 内容或名称过长")
|
||||
}
|
||||
id, err := randomHex(16)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err = c.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES (?, ?, ?, ?, ?, ?)", id, name, version, content, hex.EncodeToString(sum[:]), now); err != nil {
|
||||
return Skill{}, fmt.Errorf("无法保存 Skill: %w", err)
|
||||
}
|
||||
c.recordAudit("skill.package_saved", "controller", "skill:"+id, "completed", "已保存经哈希校验的 Skill 包。")
|
||||
return Skill{ID: id, Name: name, Version: version, Checksum: hex.EncodeToString(sum[:]), UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
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}[已脱敏]`)
|
||||
}
|
||||
Reference in New Issue
Block a user