// Package acp 实现 ACP(Agent Client Protocol)Client 模块。 // 模块结构(spec §13 决策 #12):单 package + 多文件,与现有 chat/jobs/workspace 一致。 // // 三个聚合根: // - AgentKind:admin 注册的 agent 类型(claude_code/gemini/...),含加密 env // - Session:用户启动的一个 ACP 会话,绑定 workspace + agent kind + (issue/req?) // - Event:JSON-RPC 双向消息流,每条入库 package acp import ( "context" "time" "github.com/google/uuid" ) // SessionStatus 是 acp_sessions.status 枚举。 type SessionStatus string const ( SessionStarting SessionStatus = "starting" SessionRunning SessionStatus = "running" SessionCrashed SessionStatus = "crashed" SessionExited SessionStatus = "exited" ) // IsActive 判定 session 是否仍占资源(worktree、subprocess、限流计数)。 func (s SessionStatus) IsActive() bool { return s == SessionStarting || s == SessionRunning } // EventDirection 标识事件流向。in = client→agent,out = agent→client。 type EventDirection string const ( DirectionIn EventDirection = "in" DirectionOut EventDirection = "out" ) // RPCKind 标识 JSON-RPC 消息类型。 type RPCKind string const ( RPCRequest RPCKind = "request" RPCResponse RPCKind = "response" RPCNotification RPCKind = "notification" RPCError RPCKind = "error" ) // ClientType 标识 agent CLI 的客户端类型,决定 spawn 时注入哪个配置目录 // 重定向变量(CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME)以及前端 // 渲染哪套配置表单。generic 仅注入 HOME。 type ClientType string const ( ClientClaudeCode ClientType = "claude_code" ClientCodex ClientType = "codex" ClientGemini ClientType = "gemini" ClientGeneric ClientType = "generic" ) // Valid 判定 client_type 是否为已知枚举值。 func (t ClientType) Valid() bool { switch t { case ClientClaudeCode, ClientCodex, ClientGemini, ClientGeneric: return true } return false } // ACW 自家 MCP 注入约定:session 创建时签发的 system token 与 MCP 公网地址 // 经 extraEnv 注入子进程,同时在 session/new 握手时作为 http MCP server 下发。 const ( EnvMCPToken = "ACW_MCP_TOKEN" EnvMCPURL = "ACW_MCP_URL" // AcwMcpServerName 是自家 MCP 在 mcpServers 里的保留名,第三方条目不可占用。 AcwMcpServerName = "acw" ) // McpServerType 是第三方 MCP server 的传输类型。stdio 是 ACP 基线(所有 agent // 必须支持);http / sse 需 agent 在 initialize 中声明 mcpCapabilities 才下发。 type McpServerType string const ( McpStdio McpServerType = "stdio" McpHTTP McpServerType = "http" McpSSE McpServerType = "sse" ) // McpServerSpec 是 admin 在 AgentKind 上配置的一条 MCP server。session/new // 握手时转换为 ACP wire 格式随 mcpServers 下发。Env / Headers 可能含密钥, // 整个列表加密落库(encrypted_mcp_servers);明文仅返回给 admin 编辑。 type McpServerSpec struct { Name string `json:"name"` Type McpServerType `json:"type"` Command string `json:"command,omitempty"` // stdio Args []string `json:"args,omitempty"` // stdio Env map[string]string `json:"env,omitempty"` // stdio URL string `json:"url,omitempty"` // http / sse Headers map[string]string `json:"headers,omitempty"` // http / sse } // AgentKind 是 admin 注册的 agent 类型记录。EncryptedEnv 是 AES-GCM 密文, // 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。 // EncryptedMCPServers 同为密文,但明文经 API 返回给 admin 供编辑。 type AgentKind struct { ID uuid.UUID Name string DisplayName string Description string BinaryPath string Args []string EncryptedEnv []byte Enabled bool ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批) ClientType ClientType EncryptedMCPServers []byte CreatedBy uuid.UUID CreatedAt time.Time UpdatedAt time.Time // 成本核算:关联的 llm_models 行(价格来源)+ 每 kind 默认预算上限。 // 全部 NULLABLE:未配置 model 时成本按 0/agent 自报;未配置上限时无限制。 ModelID *uuid.UUID MaxCostUSD *float64 MaxTokens *int64 MaxWallClockSeconds *int32 } // ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、 // .codex/config.toml)。RelPath 相对受管 home 目录;EncryptedContent 是 AES-GCM // 密文(内容可能含 auth token),spawn 前解密物化到受管目录。 type ConfigFile struct { ID uuid.UUID AgentKindID uuid.UUID RelPath string EncryptedContent []byte UpdatedBy uuid.UUID CreatedAt time.Time UpdatedAt time.Time } // Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自 // session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。 type Session struct { ID uuid.UUID WorkspaceID uuid.UUID ProjectID uuid.UUID AgentKindID uuid.UUID UserID uuid.UUID IssueID *uuid.UUID RequirementID *uuid.UUID AgentSessionID *string Branch string CwdPath string IsMainWorktree bool Status SessionStatus PID *int32 ExitCode *int32 LastError *string StartedAt time.Time EndedAt *time.Time // LastStopReason 是 agent 最近一次回合完成时上报的 stopReason 原始字符串 // (nil = 尚无完成的回合)。归一化枚举见 NormalizeStopReason,但落库为原值。 LastStopReason *string // OrchestratorStepID 非 nil 时表示该 session 由编排器某个 step 驱动;onExit / // 启动 reaper 据此把崩溃 session 映射回 step 并触发再驱动。manual session 为 nil。 OrchestratorStepID *uuid.UUID // 成本核算运行时累加器(落库快照)。 PromptTokens int64 CompletionTokens int64 ThinkingTokens int64 TotalCostUSD float64 LastActivityAt time.Time // 创建时快照的有效预算上限(session 覆盖 > agent-kind 默认 > 全局默认)。 BudgetMaxCostUSD *float64 BudgetMaxTokens *int64 BudgetMaxWallClockSeconds *int32 // TerminatedReason 非 nil 时记录会话被强制终止的原因(budget_*/reaper_*/manual)。 TerminatedReason *string } // BudgetCaps 是一次会话的有效预算上限快照。nil 字段 = 该维度无限制。 type BudgetCaps struct { MaxCostUSD *float64 MaxTokens *int64 MaxWallClockSeconds *int32 } // BreachReason 标识预算被突破的维度,落 acp_sessions.terminated_reason。 type BreachReason string const ( BreachCost BreachReason = "budget_cost" BreachTokens BreachReason = "budget_tokens" BreachWallClock BreachReason = "budget_wall_clock" ) // SessionUsageRecord 是 acp_session_usage 表一行(per-turn 账目,镜像 llm_usage)。 type SessionUsageRecord struct { ID int64 SessionID uuid.UUID UserID uuid.UUID ProjectID uuid.UUID AgentKindID uuid.UUID ModelID *uuid.UUID PromptTokens int64 CompletionTokens int64 ThinkingTokens int64 CostUSD float64 SourceEventID *int64 CreatedAt time.Time } // AcpUsageUserRow 按用户聚合的 ACP 用量。 type AcpUsageUserRow struct { UserID uuid.UUID PromptTokens, CompletionTokens, ThinkingTokens int64 CostUSD float64 } // AcpUsageModelRow 按模型聚合的 ACP 用量。 type AcpUsageModelRow struct { ModelID uuid.UUID PromptTokens, CompletionTokens, ThinkingTokens int64 CostUSD float64 } // AcpUsageDayRow 按天聚合的 ACP 用量。 type AcpUsageDayRow struct { Day time.Time PromptTokens, CompletionTokens, ThinkingTokens int64 CostUSD float64 } // ===== run-history dashboard 读模型 ===== // DashboardFilter 限定 run-history 汇总的范围。UserID 非 nil 时仅统计该用户的 // 会话(owner scope);nil 表示全量(admin scope)。ProjectID/RequirementID // 为可选过滤;From/To 为半开时间窗 [From, To)。 type DashboardFilter struct { UserID *uuid.UUID ProjectID *uuid.UUID RequirementID *uuid.UUID From time.Time To time.Time } // SessionRollup 是 run-history 汇总:会话计数 + 成功率 + 总成本 + 平均时长。 type SessionRollup struct { Total int64 Succeeded int64 Crashed int64 Active int64 TotalCostUSD float64 TotalTokens int64 AvgDurationSeconds float64 } // SuccessRate 返回 succeeded/total(total=0 时为 0),便于 handler/MCP 直接使用。 func (r SessionRollup) SuccessRate() float64 { if r.Total == 0 { return 0 } return float64(r.Succeeded) / float64(r.Total) } // SessionDayRow 是 run-history 按天的时间序列一行。 type SessionDayRow struct { Day time.Time Total int64 Succeeded int64 Crashed int64 TotalCostUSD float64 } // SessionTimelineBucket 是单会话事件按 (direction, rpc_kind, method) 分桶的一行。 type SessionTimelineBucket struct { Direction string RPCKind string Method string EventCount int64 FirstAt time.Time LastAt time.Time } // ReaperSession 是 reaper 需要的最小 session 投影。 type ReaperSession struct { ID uuid.UUID UserID uuid.UUID StartedAt time.Time LastActivityAt time.Time MaxWallClockSeconds *int32 } // StopReason 是回合完成的归一化原因枚举。agent 可能上报枚举外的 vendor-specific // 值;这些值归一化为 StopOther,但原始字符串仍会原样落库(stop_reason / // last_stop_reason),避免信息丢失。 type StopReason string const ( StopEndTurn StopReason = "end_turn" StopMaxTokens StopReason = "max_tokens" StopRefusal StopReason = "refusal" StopCancelled StopReason = "cancelled" StopOther StopReason = "other" ) // NormalizeStopReason 把 agent 上报的原始 stopReason 映射到归一化枚举。 // 未知值(含空串)映射为 StopOther。 func NormalizeStopReason(raw string) StopReason { switch StopReason(raw) { case StopEndTurn: return StopEndTurn case StopMaxTokens: return StopMaxTokens case StopRefusal: return StopRefusal case StopCancelled: return StopCancelled default: return StopOther } } // TurnStatus 是 acp_turns.status 枚举。 type TurnStatus string const ( TurnInProgress TurnStatus = "in_progress" TurnCompleted TurnStatus = "completed" TurnAborted TurnStatus = "aborted" ) // Turn 是一次 agent 回合的审计记录。一个 session 内 turn_index 单调递增(0-based)。 // PromptRequestID 是发起该回合的 session/prompt 的 JSON-RPC id(用于响应相关联)。 // StopReason 在完成前为 nil;存储 agent 上报的原始字符串。 type Turn struct { ID int64 SessionID uuid.UUID TurnIndex int PromptRequestID *string Status TurnStatus StopReason *string UpdateCount int StartedAt time.Time CompletedAt *time.Time } // Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON // 字节(不解析为结构体,便于无损 replay);超过 64KB 的会按 §3.5 规则裁剪。 type Event struct { ID int64 SessionID uuid.UUID Direction EventDirection RPCKind RPCKind Method *string Payload []byte PayloadSize int32 Truncated bool CreatedAt time.Time } // Caller 表示发起请求的用户上下文,与 PM 模块的 Caller 同语义。 type Caller struct { UserID uuid.UUID IsAdmin bool } // AgentKindService 暴露 AgentKind 的应用服务方法。env 走"完整 map 替换" // 语义:每次 PATCH 传完整 env,nil 表示不修改;非 nil 整体加密重写。 type AgentKindService interface { Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) List(ctx context.Context, c Caller) ([]*AgentKind, error) Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error) Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error) Delete(ctx context.Context, c Caller, id uuid.UUID) error // 配置文件管理(admin only)。与 env 不同:内容明文返回给 admin 供编辑, // 存储层加密。content 是明文。 ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error) UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error) DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error } // ConfigFileContent 是解密后的配置文件视图(service → handler)。 type ConfigFileContent struct { RelPath string Content string UpdatedAt time.Time } // SessionService 暴露 Session 聚合根的应用服务方法。 type SessionService interface { Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) Terminate(ctx context.Context, c Caller, id uuid.UUID) error // Dashboard 返回 run-history 汇总 + 按天序列。非 admin 调用强制按 caller // scope(UserID 覆盖为 caller),admin 才能查看全量。 Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error) // Timeline 返回单会话的事件时间线(先经 Get 做 owner/admin 访问校验)。 Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error) } // DashboardInput 是 run-history 汇总的入参(过滤维度由 service 在 scope 内归一化)。 type DashboardInput struct { ProjectID *uuid.UUID RequirementID *uuid.UUID From time.Time To time.Time } // DashboardResult 是 run-history 汇总响应:总览 + 按天序列。 type DashboardResult struct { Rollup SessionRollup ByDay []SessionDayRow From time.Time To time.Time } // TurnRunner 是编排器驱动一个阻塞回合所需的窄接口(不扩大 SessionService 的 // HTTP/MCP 暴露面)。sessionService 同时实现 SessionService 与 TurnRunner。 // // - SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回原始 // stopReason(来自 relay.Call 的 server-initiated 请求响应)。 // - ResumeSession 在崩溃 session 的同一 cwd/worktree 上重新拉起 agent。 type TurnRunner interface { SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (stopReason string, err error) ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error) } // SessionListFilter 控制 sessions 列表的过滤维度。 // All 仅 admin 可用(查看全部用户);RequirementID nil 表示不按需求过滤。 type SessionListFilter struct { All bool RequirementID *uuid.UUID } // CreateSessionInput 是创建 session 的入参。 // branch / issue_number / requirement_number 三选一或全空(spec §8.1)。 type CreateSessionInput struct { WorkspaceID uuid.UUID AgentKindID uuid.UUID Branch *string IssueNumber *int RequirementNumber *int InitialPrompt *string // OrchestratorStepID 非 nil 时把 session 与编排器 step 反向关联(仅由 orchestrator // 内部调用设置;HTTP/MCP 入口不暴露)。 OrchestratorStepID *uuid.UUID } // CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。 // ClientType 为空时默认 generic。 type CreateAgentKindInput struct { Name string DisplayName string Description string BinaryPath string Args []string Env map[string]string Enabled bool ToolAllowlist []string ClientType ClientType MCPServers []McpServerSpec // 成本核算:model 价格来源 + 默认预算上限(均可选)。 ModelID *uuid.UUID MaxCostUSD *float64 MaxTokens *int64 MaxWallClockSeconds *int32 } // UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。 // Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。 type UpdateAgentKindInput struct { DisplayName *string Description *string BinaryPath *string Args []string // nil = 不改;非 nil = 替换 Env map[string]string // nil = 不改;非 nil(含空)= 替换 Enabled *bool ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换 ClientType *ClientType // nil = 不改 MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换 // 成本核算字段:三态指针,nil = 不改。SetModelID 控制 ModelID 是否参与更新 // (ModelID 本身可被显式置空,故用独立 flag 区分"不改"与"清空")。 SetModelID bool ModelID *uuid.UUID SetMaxCostUSD bool MaxCostUSD *float64 SetMaxTokens bool MaxTokens *int64 SetMaxWallClock bool MaxWallClockSeconds *int32 } // PermissionStatus 是 acp_permission_requests.status 枚举。 type PermissionStatus string const ( PermissionPending PermissionStatus = "pending" PermissionApproved PermissionStatus = "approved" PermissionDenied PermissionStatus = "denied" PermissionAuto PermissionStatus = "auto" PermissionExpired PermissionStatus = "expired" ) // PermissionRequest 是一条代理发起的工具调用授权请求记录。 type PermissionRequest struct { ID uuid.UUID SessionID uuid.UUID AgentRequestID string ToolName string ToolCall []byte // 原始 toolCall JSON Options []byte // 原始 options JSON Status PermissionStatus ChosenOptionID *string DecidedBy *uuid.UUID DecidedAt *time.Time CreatedAt time.Time }