This commit is contained in:
toot
2026-09-16 18:11:48 +08:00
parent 9e01cbbec0
commit ada97a7497
27 changed files with 661 additions and 45 deletions
@@ -35,6 +35,8 @@ type AgentDirectoryItem struct {
MCPStatus string `json:"mcpStatus"`
MCPFailure string `json:"mcpFailure"`
LastDeploymentAt string `json:"lastDeploymentAt"`
RestartStatus string `json:"restartStatus"`
RestartFailure string `json:"restartFailure"`
}
type AgentDirectoryPage struct {
@@ -85,11 +87,13 @@ var agentDirectoryBaseSQL = `WITH skill_summary AS (
` + mcpStatusExpression("a.agent_id") + ` AS mcp_status,
` + mcpErrorExpression("a.agent_id") + ` AS mcp_failure,
COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), '') AS mcp_updated_at,
MAX(COALESCE(l.updated_at, ''), COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), ''), COALESCE(s.updated_at, '')) AS last_deployment_at
MAX(COALESCE(l.updated_at, ''), COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), ''), COALESCE(s.updated_at, '')) AS last_deployment_at,
COALESCE(r.status, '') AS restart_status, COALESCE(r.failure, '') AS restart_failure
FROM managed_agents a
LEFT JOIN agent_llm_assignments l ON l.agent_id = a.agent_id
LEFT JOIN llm_profiles p ON p.profile_id = l.profile_id
LEFT JOIN skill_summary s ON s.agent_id = a.agent_id
LEFT JOIN agent_restart_operations r ON r.agent_id = a.agent_id
)
`
@@ -164,7 +168,7 @@ func (c *ControlPlane) AgentDirectory(filter AgentDirectoryFilter) (AgentDirecto
}
queryArgs := append(append([]any{}, args...), filter.PageSize, (page.Page-1)*filter.PageSize)
rows, err := c.db.Query(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at, restart_status, restart_failure
FROM directory`+where+" ORDER BY CASE status WHEN 'online' THEN 0 ELSE 1 END, name COLLATE NOCASE, agent_id LIMIT ? OFFSET ?", queryArgs...)
if err != nil {
return page, fmt.Errorf("无法读取 Agent 列表: %w", err)
@@ -172,7 +176,7 @@ func (c *ControlPlane) AgentDirectory(filter AgentDirectoryFilter) (AgentDirecto
defer rows.Close()
for rows.Next() {
var item AgentDirectoryItem
if err := rows.Scan(&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt); err != nil {
if err := rows.Scan(&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt, &item.RestartStatus, &item.RestartFailure); err != nil {
return page, err
}
item.LLMFailure, item.SkillFailure, item.MCPFailure = sanitizeDisplayText(item.LLMFailure), sanitizeDisplayText(item.SkillFailure), sanitizeDisplayText(item.MCPFailure)
@@ -189,8 +193,8 @@ func (c *ControlPlane) AgentOperationalDetails(agentID string) (AgentOperational
// Fetch by ID directly so duplicate display names cannot select another Agent.
var item AgentDirectoryItem
err := c.db.QueryRow(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at FROM directory WHERE agent_id = ?`, agentID).Scan(
&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt,
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at, restart_status, restart_failure FROM directory WHERE agent_id = ?`, agentID).Scan(
&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt, &item.RestartStatus, &item.RestartFailure,
)
if errors.Is(err, sql.ErrNoRows) {
return AgentOperationalDetail{}, errors.New("未找到 Agent")
@@ -198,7 +202,7 @@ func (c *ControlPlane) AgentOperationalDetails(agentID string) (AgentOperational
if err != nil {
return AgentOperationalDetail{}, fmt.Errorf("无法读取 Agent 详情: %w", err)
}
detail := AgentOperationalDetail{Agent: Agent{ID: item.ID, Name: item.Name, Status: item.Status, LastSeenAt: item.LastSeenAt}, Deployment: item, RecentTasks: make([]Task, 0), RecentAudit: make([]AuditEvent, 0), DeploymentHistory: make([]AuditEvent, 0), Failures: make([]DeploymentFailure, 0)}
detail := AgentOperationalDetail{Agent: Agent{ID: item.ID, Name: item.Name, Status: item.Status, LastSeenAt: item.LastSeenAt, RestartStatus: item.RestartStatus, RestartFailure: item.RestartFailure}, Deployment: item, RecentTasks: make([]Task, 0), RecentAudit: make([]AuditEvent, 0), DeploymentHistory: make([]AuditEvent, 0), Failures: make([]DeploymentFailure, 0)}
detail.RecentTasks, err = c.recentTasksForAgent(agentID, 8)
if err != nil {
return detail, err
@@ -50,10 +50,12 @@ type Snapshot struct {
// Agent is a safe summary of a registered Agent.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LastSeenAt string `json:"lastSeenAt"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LastSeenAt string `json:"lastSeenAt"`
RestartStatus string `json:"restartStatus"`
RestartFailure string `json:"restartFailure"`
}
// Task is a persisted controller task summary. Credentials never appear here.
@@ -323,7 +325,8 @@ func (c *ControlPlane) removeRevokedAgents() {
}
func (c *ControlPlane) queryAgents(where string) ([]Agent, error) {
rows, err := c.db.Query("SELECT agent_id, name, status, last_seen_at FROM managed_agents " + where + " ORDER BY name")
where = strings.ReplaceAll(where, "status", "a.status")
rows, err := c.db.Query("SELECT a.agent_id, a.name, a.status, a.last_seen_at, COALESCE(r.status, ''), COALESCE(r.failure, '') FROM managed_agents a LEFT JOIN agent_restart_operations r ON r.agent_id = a.agent_id " + where + " ORDER BY a.name")
if err != nil {
return nil, err
}
@@ -331,7 +334,7 @@ func (c *ControlPlane) queryAgents(where string) ([]Agent, error) {
agents := make([]Agent, 0)
for rows.Next() {
var agent Agent
if err := rows.Scan(&agent.ID, &agent.Name, &agent.Status, &agent.LastSeenAt); err != nil {
if err := rows.Scan(&agent.ID, &agent.Name, &agent.Status, &agent.LastSeenAt, &agent.RestartStatus, &agent.RestartFailure); err != nil {
return nil, err
}
agents = append(agents, agent)
@@ -511,10 +514,20 @@ func (c *ControlPlane) CancelTask(taskID string) error {
if client == nil {
return errors.New("目标 Agent 当前不在线,暂不能取消")
}
// Persist the cancellation intent before sending. An Agent can respond on
// another goroutine immediately; recording it first makes every late
// completed/failed event ineligible to overwrite the requested outcome.
result, err := c.db.Exec("UPDATE task_runs SET status = 'cancelling' WHERE task_id = ? AND status = ?", taskID, status)
if err != nil {
return errors.New("无法记录取消状态,请刷新后重试")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("任务状态已变化,请刷新后重试")
}
if err := client.send(map[string]any{"type": "task_cancel", "schema_version": protocolVersion, "task_id": taskID, "reason": "由总控取消"}); err != nil {
_, _ = c.db.Exec("UPDATE task_runs SET status = ? WHERE task_id = ? AND status = 'cancelling'", status, taskID)
return errors.New("取消指令未发送成功,请刷新后重试")
}
_, _ = c.db.Exec("UPDATE task_runs SET status = 'cancelling' WHERE task_id = ?", taskID)
c.recordEvent(taskID, "cancelling", "已向 Agent 发送取消指令。", time.Now().UTC().Format(time.RFC3339Nano))
c.recordAudit("task.cancel_requested", "controller", "agent:"+agentID, "cancelling", "已向 Agent 请求取消任务。")
return nil
@@ -723,6 +736,10 @@ func (c *ControlPlane) handleMessage(client *connection, message map[string]any)
if client.agentID != "" {
c.applyMCPAck(client.agentID, message)
}
case "agent_restart_ack":
if client.agentID != "" {
c.applyRestartAck(client.agentID, message)
}
default:
_ = client.send(map[string]any{"type": "error", "message": "不支持的消息类型。"})
}
@@ -738,6 +755,7 @@ func (c *ControlPlane) attach(client *connection, agentID string) {
_ = previous.ws.Close()
}
c.setAgentStatus(agentID, "online")
c.markRestartReconnected(agentID)
}
// pushConfigurationSnapshots starts only after the authentication acknowledgement
@@ -877,6 +895,12 @@ func (c *ControlPlane) applyTaskEvent(agentID string, event map[string]any) {
if c.db.QueryRow("SELECT agent_id, status, timeout_seconds, COALESCE(session_id, ''), instruction FROM task_runs WHERE task_id = ?", taskID).Scan(&expectedAgentID, &currentStatus, &timeoutSeconds, &sessionID, &instruction) != nil || expectedAgentID != agentID || isTerminal(currentStatus) {
return
}
// Cancellation is a write-once intent. An already-running model or MCP
// request may return late, but it must not change the requested terminal
// outcome to completed/failed or resurrect the task as running.
if currentStatus == "cancelling" && status != "cancelled" {
return
}
message, _ := event["message"].(string)
output, _ := event["output"].(string)
errText, _ := event["error"].(string)
@@ -1065,6 +1089,7 @@ func initialiseSchema(db *sql.DB) error {
"CREATE TABLE IF NOT EXISTS task_sessions (session_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, turn_limit INTEGER NOT NULL DEFAULT 50, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS task_session_messages (session_id TEXT NOT NULL, position INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(session_id, position))",
"CREATE TABLE IF NOT EXISTS audit_events (event_id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, actor TEXT NOT NULL, target TEXT NOT NULL, outcome TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, operator TEXT NOT NULL DEFAULT '', target_agent_id TEXT NOT NULL DEFAULT '', resource_type TEXT NOT NULL DEFAULT 'system', result TEXT NOT NULL DEFAULT '', security_detail TEXT NOT NULL DEFAULT '')",
"CREATE TABLE IF NOT EXISTS agent_restart_operations (agent_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, status TEXT NOT NULL, requested_at TEXT NOT NULL, confirmed_at TEXT, failure TEXT NOT NULL DEFAULT '')",
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
+104
View File
@@ -0,0 +1,104 @@
package controlplane
import (
"errors"
"fmt"
"strings"
"time"
)
const restartCooldown = 60 * time.Second
// AgentRestart is the safe lifecycle state rendered by the controller. It
// contains no remote process details or credentials.
type AgentRestart struct {
RequestID string `json:"requestID"`
AgentID string `json:"agentID"`
Status string `json:"status"`
RequestedAt string `json:"requestedAt"`
ConfirmedAt string `json:"confirmedAt"`
Failure string `json:"failure"`
}
// RestartAgent sends an authenticated command over the existing paired WSS
// session. The controller deliberately never starts or terminates a remote
// machine process itself.
func (c *ControlPlane) RestartAgent(agentID string) (AgentRestart, error) {
agentID = strings.TrimSpace(agentID)
if agentID == "" {
return AgentRestart{}, errors.New("Agent 标识无效")
}
c.expireStaleAgents()
var status string
if err := c.db.QueryRow("SELECT status FROM managed_agents WHERE agent_id = ?", agentID).Scan(&status); err != nil {
return AgentRestart{}, errors.New("未找到 Agent")
}
client := c.clientFor(agentID)
if status != "online" || client == nil {
c.recordAudit("remote.restart_rejected", "controller", "agent:"+agentID, "failed", "目标 Agent 当前离线,无法通过已认证连接发送重启指令。")
return AgentRestart{}, errors.New("目标 Agent 当前离线,无法重启;请等待其重新连接")
}
var existing AgentRestart
err := c.db.QueryRow("SELECT request_id, agent_id, status, requested_at, COALESCE(confirmed_at, ''), COALESCE(failure, '') FROM agent_restart_operations WHERE agent_id = ?", agentID).Scan(&existing.RequestID, &existing.AgentID, &existing.Status, &existing.RequestedAt, &existing.ConfirmedAt, &existing.Failure)
if err == nil {
if existing.Status == "restarting" || existing.Status == "sent" {
return existing, nil // idempotent repeated click; do not send another command.
}
if existing.Status == "reconnected" {
if requested, parseErr := time.Parse(time.RFC3339Nano, existing.RequestedAt); parseErr == nil && time.Since(requested) < restartCooldown {
return existing, errors.New("该 Agent 刚完成重启,请稍后再试")
}
}
}
requestID, err := randomHex(16)
if err != nil {
return AgentRestart{}, err
}
now := time.Now().UTC().Format(time.RFC3339Nano)
op := AgentRestart{RequestID: requestID, AgentID: agentID, Status: "restarting", RequestedAt: now}
if _, err := c.db.Exec(`INSERT INTO agent_restart_operations(request_id, agent_id, status, requested_at, confirmed_at, failure)
VALUES (?, ?, ?, ?, '', '') ON CONFLICT(agent_id) DO UPDATE SET request_id=excluded.request_id, status=excluded.status, requested_at=excluded.requested_at, confirmed_at='', failure=''`, requestID, agentID, op.Status, now); err != nil {
return AgentRestart{}, fmt.Errorf("无法记录重启操作: %w", err)
}
c.recordAudit("remote.restart_requested", "controller", "agent:"+agentID, "restarting", "已通过认证控制连接请求 Agent 重启。")
if err := client.send(map[string]any{"type": "agent_restart", "schema_version": protocolVersion, "request_id": requestID, "sent_at": now}); err != nil {
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='failed', failure=? WHERE agent_id=? AND request_id=?", "重启指令未发送成功。", agentID, requestID)
c.recordAudit("remote.restart_delivery", "controller", "agent:"+agentID, "failed", "重启指令未发送成功。")
return AgentRestart{}, errors.New("重启指令未发送成功,请刷新后重试")
}
c.recordAudit("remote.restart_delivery", "controller", "agent:"+agentID, "sent", "重启指令已下发,等待 Agent 确认。")
return op, nil
}
func (c *ControlPlane) applyRestartAck(agentID string, message map[string]any) {
requestID, _ := message["request_id"].(string)
status, _ := message["status"].(string)
errorText, _ := message["error"].(string)
if requestID == "" || (status != "accepted" && status != "restarting" && status != "failed") {
return
}
var expected string
if c.db.QueryRow("SELECT request_id FROM agent_restart_operations WHERE agent_id=?", agentID).Scan(&expected) != nil || expected != requestID {
return
}
if status == "failed" {
detail := limit(sanitizeDisplayText(errorText), 1000)
if detail == "" {
detail = "Agent 未能启动替代进程。"
}
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='failed', confirmed_at=?, failure=? WHERE agent_id=?", time.Now().UTC().Format(time.RFC3339Nano), detail, agentID)
c.recordAudit("remote.restart_confirmed", "agent:"+agentID, "agent:"+agentID, "failed", detail)
return
}
_, _ = c.db.Exec("UPDATE agent_restart_operations SET status='restarting', confirmed_at=?, failure='' WHERE agent_id=?", time.Now().UTC().Format(time.RFC3339Nano), agentID)
c.recordAudit("remote.restart_confirmed", "agent:"+agentID, "agent:"+agentID, "restarting", "Agent 已确认正在安全结束任务并重启。")
}
func (c *ControlPlane) markRestartReconnected(agentID string) {
result, err := c.db.Exec("UPDATE agent_restart_operations SET status='reconnected', confirmed_at=?, failure='' WHERE agent_id=? AND status IN ('sent', 'restarting')", time.Now().UTC().Format(time.RFC3339Nano), agentID)
if err == nil {
if changed, _ := result.RowsAffected(); changed == 1 {
c.recordAudit("remote.restart_reconnected", "agent:"+agentID, "agent:"+agentID, "reconnected", "替代 Agent 已重新通过认证并恢复连接。")
}
}
}
@@ -0,0 +1,122 @@
package controlplane
import (
"crypto/tls"
"net"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/gorilla/websocket"
)
func newRestartTestController(t *testing.T) (*ControlPlane, string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close()
configPath := filepath.Join(t.TempDir(), "controller-config.json")
if err := os.WriteFile(configPath, []byte(`{"agent_host":"127.0.0.1","agent_port":`+strconv.Itoa(port)+`}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(configPath)
if err != nil {
t.Fatal(err)
}
if err := controller.StartAgentServer(); err != nil {
t.Fatal(err)
}
return controller, "wss://127.0.0.1:" + strconv.Itoa(port)
}
func pairRestartTestAgent(t *testing.T, controller *ControlPlane, url string) (*websocket.Conn, string, string) {
t.Helper()
client, _, err := (&websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}).Dial(url, nil) //nolint:gosec -- test-only TLS.
if err != nil {
t.Fatal(err)
}
code, err := controller.CreatePairingCode()
if err != nil {
t.Fatal(err)
}
if err := client.WriteJSON(map[string]any{"type": "pair_request", "code": code, "agent_name": "restart-agent"}); err != nil {
t.Fatal(err)
}
var paired map[string]any
if err := client.ReadJSON(&paired); err != nil {
t.Fatal(err)
}
return client, paired["agent_id"].(string), paired["credential"].(string)
}
func TestRestartOnlineAgentIsIdempotentAndReconnects(t *testing.T) {
controller, url := newRestartTestController(t)
defer controller.Close()
client, agentID, credential := pairRestartTestAgent(t, controller, url)
defer client.Close()
first, err := controller.RestartAgent(agentID)
if err != nil || first.Status != "restarting" {
t.Fatalf("restart request failed: %#v / %v", first, err)
}
var command map[string]any
if err := client.ReadJSON(&command); err != nil || command["type"] != "agent_restart" || command["request_id"] != first.RequestID {
t.Fatalf("expected restart command, got %#v / %v", command, err)
}
second, err := controller.RestartAgent(agentID)
if err != nil || second.RequestID != first.RequestID {
t.Fatalf("duplicate must reuse operation: %#v / %v", second, err)
}
if err := client.WriteJSON(map[string]any{"type": "agent_restart_ack", "request_id": first.RequestID, "status": "accepted"}); err != nil {
t.Fatal(err)
}
time.Sleep(25 * time.Millisecond)
_ = client.Close()
replacement, _, err := (&websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}).Dial(url, nil) //nolint:gosec -- test-only TLS.
if err != nil {
t.Fatal(err)
}
defer replacement.Close()
if err := replacement.WriteJSON(map[string]any{"type": "authenticate", "agent_id": agentID, "credential": credential, "agent_name": "restart-agent"}); err != nil {
t.Fatal(err)
}
var accepted map[string]any
if err := replacement.ReadJSON(&accepted); err != nil || accepted["type"] != "auth_accepted" {
t.Fatalf("replacement did not reconnect: %#v / %v", accepted, err)
}
var status string
if err := controller.db.QueryRow("SELECT status FROM agent_restart_operations WHERE agent_id=?", agentID).Scan(&status); err != nil || status != "reconnected" {
t.Fatalf("restart state = %q / %v", status, err)
}
}
func TestRestartOfflineAgentIsRejectedAndLateCompletionCannotWin(t *testing.T) {
controller, _ := newRestartTestController(t)
defer controller.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('offline', 'offline', 'hash', 'offline', ?)", now); err != nil {
t.Fatal(err)
}
if _, err := controller.RestartAgent("offline"); err == nil {
t.Fatal("offline Agent restart must be rejected")
}
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds) VALUES ('cancelled-late', 'offline', 'offline', 'test', 'cancelling', ?, 30)", now); err != nil {
t.Fatal(err)
}
controller.applyTaskEvent("offline", map[string]any{"task_id": "cancelled-late", "status": "completed", "output": "late"})
var taskStatus string
if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id='cancelled-late'").Scan(&taskStatus); err != nil || taskStatus != "cancelling" {
t.Fatalf("late completion overwrote cancellation: %q / %v", taskStatus, err)
}
controller.applyTaskEvent("offline", map[string]any{"task_id": "cancelled-late", "status": "cancelled", "message": "已取消"})
if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id='cancelled-late'").Scan(&taskStatus); err != nil || taskStatus != "cancelled" {
t.Fatalf("cancel acknowledgement missing: %q / %v", taskStatus, err)
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// same limit so content is never silently truncated after deployment.
const maxSkillContentBytes = 50000
// Skill is a safe Markdown-only capability guide owned by the controller.
// Skill is a Markdown-only capability guide owned by the controller.
type Skill struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -114,7 +114,7 @@ func (c *ControlPlane) SaveSkill(input SkillInput) (Skill, error) {
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 包。")
c.recordAudit("skill.package_saved", "controller", "skill:"+skillID, "completed", "已保存 Skill 包。")
case err != nil:
return Skill{}, fmt.Errorf("无法读取 Skill: %w", err)
default: