You've already forked agentic-coding-workflow
1857 lines
76 KiB
Go
1857 lines
76 KiB
Go
// Package app 是应用的装配根:把 config、logger、DB 池、各业务模块、HTTP
|
|
// 路由按依赖顺序拼起来,并暴露 Run 给入口程序。本包只做组合,不引入新业务。
|
|
//
|
|
// 启动顺序:
|
|
// 1. 用 config.Env 派生 logger(FromEnv 在生产环境默认开启 source)。
|
|
// 2. 跑 migrations(先 migrate 再开池,避免应用启动时表还没建好;migrate
|
|
// 失败直接返错由调用方记录、退出)。
|
|
// 3. 创建 *db.Pool(实际是 *pgxpool.Pool 别名),失败时不再继续。
|
|
// 4. 模块装配:audit recorder -> user repo+service(注入 audit)-> notify
|
|
// repo+dispatcher(注入 dummy notifier)-> notify handler(复用 userSvc
|
|
// 作为 SessionResolver,user.Service 接口本身满足该契约)。
|
|
// 5. 路由:httpx.NewRouter 返回 chi.Router 接口,直接挂 RequestID/Recover/
|
|
// Logger 三个全局中间件,再让模块 Mount 自己的子路由。
|
|
// 6. 可选 bootstrap:仅当配置里同时给了 admin 邮箱/密码时尝试。失败仅
|
|
// Warn 不阻塞启动。
|
|
// 7. 把所有依赖封装到 *App,由 Run 负责启动 http server 与优雅关停。
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp/sandbox"
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/brief"
|
|
"github.com/yan1h/agent-coding-workflow/internal/changerequest"
|
|
"github.com/yan1h/agent-coding-workflow/internal/chat"
|
|
"github.com/yan1h/agent-coding-workflow/internal/codeindex"
|
|
"github.com/yan1h/agent-coding-workflow/internal/config"
|
|
"github.com/yan1h/agent-coding-workflow/internal/docs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/metrics"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/storage"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/vcs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
|
mcp "github.com/yan1h/agent-coding-workflow/internal/mcp"
|
|
"github.com/yan1h/agent-coding-workflow/internal/orchestrator"
|
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
|
"github.com/yan1h/agent-coding-workflow/internal/projectmemory"
|
|
runmod "github.com/yan1h/agent-coding-workflow/internal/run"
|
|
"github.com/yan1h/agent-coding-workflow/internal/security"
|
|
"github.com/yan1h/agent-coding-workflow/internal/terminal"
|
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
|
httpmw "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
|
"github.com/yan1h/agent-coding-workflow/internal/user"
|
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
|
)
|
|
|
|
// App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。
|
|
type App struct {
|
|
cfg *config.Config
|
|
log *slog.Logger
|
|
pool *db.Pool
|
|
server *http.Server
|
|
dispatcher *notify.Dispatcher
|
|
jobs *jobs.Module
|
|
jobsCancel context.CancelFunc
|
|
acpSup *acp.Supervisor // ACP supervisor — ShutdownAll on Run shutdown
|
|
runSup *runmod.RunSupervisor // run profiles supervisor — ShutdownAll on Run shutdown
|
|
termSup *terminal.Supervisor // admin terminal supervisor — CloseAll on shutdown
|
|
}
|
|
|
|
// New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。
|
|
// dist 是 cmd/server 用 //go:embed 注入的前端构建产物,根目录为 web/dist;
|
|
// fs.Sub 之后交给 SPA handler 提供静态文件 + index.html 回退。
|
|
func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|
log := logger.FromEnv(cfg.Env, nil)
|
|
|
|
// 跑 migrations 必须在开池前;否则首次启动连不存在的表会让 ping 失败。
|
|
if err := db.Migrate(ctx, cfg.DB.DSN, "file://migrations"); err != nil {
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
pool, err := db.NewPool(ctx, cfg.DB.DSN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
auditRec := audit.NewPostgresRecorder(pool)
|
|
wrappedAudit := mcp.AuditWrap(auditRec) // 给业务 service 用,自动注入 via_mcp metadata
|
|
auditReader := audit.NewPostgresReader(pool)
|
|
|
|
// ===== observability:Prometheus 指标注册中心 =====
|
|
// GaugeFunc 数据源用独立 pool 查询,避免与模块构造顺序耦合(acpRepo / chatRepo
|
|
// 此时尚未构造)。查询失败时回退 0,不阻断 /metrics scrape。
|
|
var appMetrics *metrics.Metrics
|
|
if cfg.Metrics.Enabled {
|
|
activeSessionsFn := func() float64 {
|
|
qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
var n int64
|
|
if err := pool.QueryRow(qctx,
|
|
`SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting','running')`).Scan(&n); err != nil {
|
|
return 0
|
|
}
|
|
return float64(n)
|
|
}
|
|
llmCostTotalFn := func() float64 {
|
|
qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
var cost float64
|
|
if err := pool.QueryRow(qctx,
|
|
`SELECT COALESCE(SUM(cost_usd),0)::float8 FROM llm_usage`).Scan(&cost); err != nil {
|
|
return 0
|
|
}
|
|
return cost
|
|
}
|
|
appMetrics = metrics.New(activeSessionsFn, llmCostTotalFn)
|
|
}
|
|
|
|
// ===== secret 加密:versioned KeyedEncryptor + Postgres key store =====
|
|
// provider 据 cfg.Crypto.Provider 选取(env|vault|kms);vault/kms 客户端尚未
|
|
// 接入,选中时退回 env provider(version 1 = APP_MASTER_KEY),保持启动不破。
|
|
cryptoProvider := buildCryptoProvider(cfg, log)
|
|
cryptoKeyStore := crypto.NewPostgresKeyStore(pool)
|
|
keyedEnc := crypto.NewKeyedEncryptor(cryptoProvider, cryptoKeyStore)
|
|
// enc 是 v1 shim,背靠同一个 KeyedEncryptor:~30 个既有调用点(workspace/chat/
|
|
// acp/run 的 *crypto.Encryptor 参数)无需改动。需要按版本持久化新密文的少数
|
|
// 调用点(re-encrypt job / rotate-key)直接用 keyedEnc。
|
|
// 注意:shim 的写版本固定为 1(与历史密文格式一致);rotate 后由 re-encrypt job
|
|
// 把旧行迁到新版本,shim 仍能解 v1(DB key_version 列携带版本)。
|
|
enc := crypto.NewEncryptorFromKeyed(keyedEnc, 1)
|
|
|
|
userRepo := user.NewPostgresRepository(pool)
|
|
userSvc := user.NewService(userRepo, auditRec)
|
|
// 登录暴力破解节流:装配期回填启用的实例(cfg-gated)。
|
|
if setter, ok := userSvc.(user.LoginThrottleSetter); ok {
|
|
setter.SetLoginThrottle(user.NewLoginThrottle(user.LoginThrottleConfig{
|
|
Enabled: cfg.User.LoginThrottle.Enabled,
|
|
MaxFailures: cfg.User.LoginThrottle.MaxFailures,
|
|
Window: cfg.User.LoginThrottle.Window,
|
|
}, nil))
|
|
}
|
|
|
|
// notifyDispatcher 先于 project 服务构造:phase-change 领域事件经其 fan-out。
|
|
notifyRepo := notify.NewPostgresRepository(pool)
|
|
notifyPrefsRepo := notify.NewPostgresPrefsRepository(pool)
|
|
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
|
notifyDispatcher.Register(notify.NewDummyNotifier(log))
|
|
// inbox 实时推送:per-user SSE 扇出中心。Dispatch 落库成功后 Publish。
|
|
notifyStream := notify.NewStreamHub()
|
|
notifyDispatcher.SetStreamHub(notifyStream)
|
|
// email / im 外发通道:仅在配置启用时注册。二者均消费 notification_prefs
|
|
// 做 per-user 开关 + min_severity 过滤;in-app inbox 始终投递不受影响。
|
|
if cfg.Notify.Email.Enabled {
|
|
notifyDispatcher.Register(notify.NewEmailNotifier(notify.EmailConfig{
|
|
Enabled: cfg.Notify.Email.Enabled,
|
|
SMTPHost: cfg.Notify.Email.SMTPHost,
|
|
Port: cfg.Notify.Email.Port,
|
|
From: cfg.Notify.Email.From,
|
|
Username: cfg.Notify.Email.Username,
|
|
Password: cfg.Notify.Email.Password,
|
|
}, notifyPrefsRepo, emailLookupAdapter{svc: userSvc}, nil, log))
|
|
}
|
|
if cfg.Notify.IM.Enabled {
|
|
notifyDispatcher.Register(notify.NewIMNotifier(notify.IMConfig{
|
|
Enabled: cfg.Notify.IM.Enabled,
|
|
}, notifyPrefsRepo, enc, nil, log))
|
|
}
|
|
|
|
projectRepo := project.NewPostgresRepository(pool)
|
|
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
|
|
// crRepo 在此提前构造(仅需 pool),供 Done 网关查询 workspace PR 合并状态;
|
|
// 同一实例在下方 change-request 装配处复用(无状态,复用安全)。
|
|
crRepo := changerequest.NewPostgresRepository(pool)
|
|
// 阶段网关引擎:Repo 查阶段指针 / 产物 verdict;Workspace 查 PR 合并状态(Done 网关)。
|
|
phaseGate := project.NewGateRunner(project.GateDeps{
|
|
Repo: projectRepo,
|
|
Workspace: workspaceStateAdapter{crRepo: crRepo},
|
|
}, nil)
|
|
phaseEmitter := phaseEventEmitterAdapter{d: notifyDispatcher}
|
|
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit, phaseEmitter, phaseGate)
|
|
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
|
|
artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit)
|
|
|
|
spaSub, err := fs.Sub(dist, "web/dist")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sub fs: %w", err)
|
|
}
|
|
spaHandler := httpx.NewSPAHandler(spaSub)
|
|
|
|
r := httpx.NewRouter(httpx.RouterDeps{
|
|
HealthCheck: func(req *http.Request) error {
|
|
return db.HealthCheck(req.Context(), pool)
|
|
},
|
|
ReadyCheck: func(req *http.Request) error {
|
|
return db.HealthCheck(req.Context(), pool)
|
|
},
|
|
SPAHandler: spaHandler,
|
|
Logger: log,
|
|
Security: httpmw.SecurityHeadersConfig{
|
|
Enabled: cfg.HTTP.Security.Enabled,
|
|
TLSEnabled: cfg.HTTP.TLS.Enabled,
|
|
HSTSMaxAgeSeconds: cfg.HTTP.Security.HSTSMaxAgeSeconds,
|
|
FrameOptions: cfg.HTTP.Security.FrameOptions,
|
|
ReferrerPolicy: cfg.HTTP.Security.ReferrerPolicy,
|
|
ContentSecurityPolicy: cfg.HTTP.Security.ContentSecurityPolicy,
|
|
},
|
|
CORS: httpmw.CORSConfig{
|
|
Enabled: cfg.HTTP.CORS.Enabled,
|
|
AllowedOrigins: cfg.HTTP.CORS.AllowedOrigins,
|
|
AllowedMethods: cfg.HTTP.CORS.AllowedMethods,
|
|
AllowedHeaders: cfg.HTTP.CORS.AllowedHeaders,
|
|
AllowCredentials: cfg.HTTP.CORS.AllowCredentials,
|
|
MaxAgeSeconds: cfg.HTTP.CORS.MaxAgeSeconds,
|
|
},
|
|
MetricsHandler: func() http.Handler {
|
|
if appMetrics == nil {
|
|
return nil
|
|
}
|
|
return appMetrics.Handler()
|
|
}(),
|
|
MetricsAuthToken: cfg.Metrics.AuthToken,
|
|
})
|
|
|
|
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
|
|
user.NewHandler(userSvc).Mount(r)
|
|
notify.NewHandler(notifyRepo, userSvc).WithStreamHub(notifyStream).WithPrefs(notifyPrefsRepo, enc).Mount(r)
|
|
// admin-only 审计日志查看器(GET /api/v1/admin/audit-logs)。
|
|
audit.NewHandler(auditReader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
|
|
// ===== workspace 模块装配 =====
|
|
// 依赖图:
|
|
// git.Runner (cfg) ─┐
|
|
// crypto.Encryptor ─┼─ workspaceService (clones=nil) ─┐
|
|
// workspace.Repo ───┘ ├─ CloneRunner ─┐
|
|
// │ │
|
|
// SetScheduler ───┴───────────────┘
|
|
// │
|
|
// WorktreeService / GitOpsService
|
|
// wsDataDir 是数据根;pathing.MainPath/WorktreePath 内部会再 join "workspaces" 子目录。
|
|
// 这里显式 mkdir 出 ${dataDir}/workspaces 让首个 clone 的父目录已经存在。
|
|
wsDataDir := cfg.Workspace.DataDir
|
|
if err := os.MkdirAll(filepath.Join(wsDataDir, "workspaces"), 0o755); err != nil {
|
|
return nil, fmt.Errorf("mkdir workspace data dir: %w", err)
|
|
}
|
|
tmpDir := filepath.Join(wsDataDir, "tmp")
|
|
// 启动期清掉残留 tmp(可能含上次崩溃留下的 askpass 脚本与 SSH 私钥)
|
|
_ = os.RemoveAll(tmpDir)
|
|
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("mkdir workspace tmp: %w", err)
|
|
}
|
|
|
|
gitr := git.NewDefaultRunner(git.Config{
|
|
Binary: cfg.Git.Binary,
|
|
TmpDir: tmpDir,
|
|
CloneTimeout: cfg.Workspace.CloneTimeout,
|
|
FetchTimeout: cfg.Workspace.FetchTimeout,
|
|
PushTimeout: cfg.Workspace.PushTimeout,
|
|
OpsTimeout: cfg.Workspace.OpsTimeout,
|
|
})
|
|
|
|
// 服务端上下文组装器:把 (requirement|issue + 阶段产物 + 仓库定向 + 操作者文本)
|
|
// 组装成 token 受限的任务简报。注入 ACP session 初始 prompt 与 chat 的 FK 上下文。
|
|
// orienter 走同一个 git runner(仅 ACP 场景且有 cwd 时才做仓库定向)。
|
|
briefComposer := brief.NewComposer(brief.NewRepoOrienter(gitr), brief.Config{
|
|
DefaultTokenBudget: cfg.Acp.BriefTokenBudget,
|
|
})
|
|
|
|
wsRepo := workspace.NewPostgresRepository(pool)
|
|
// 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住;
|
|
// 每个被复位的工作区写一条 workspace.sync_aborted_on_restart audit。
|
|
if abortedIDs, err := wsRepo.ResetStuckSyncStatuses(ctx); err != nil {
|
|
log.Warn("reset stuck workspace sync statuses", "err", err.Error())
|
|
} else {
|
|
for _, wsID := range abortedIDs {
|
|
if rerr := auditRec.Record(ctx, audit.Entry{
|
|
Action: "workspace.sync_aborted_on_restart",
|
|
TargetType: "workspace",
|
|
TargetID: wsID.String(),
|
|
}); rerr != nil {
|
|
log.Warn("workspace.sync_aborted_on_restart audit failed",
|
|
"workspace_id", wsID, "err", rerr.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
pa := projectAccessAdapter{p: projectSvc}
|
|
// 先以 nil scheduler 构造 service,cloneRunner 再回填(环依赖:runner 需要凭据访问器,访问器在 service 上)
|
|
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, wsDataDir)
|
|
|
|
// 共享凭据加载器:CloneRunner 与 GitOpsService 都需要。
|
|
// 通过 CredentialAccessor 接口避免直接持有 *workspaceService 私有类型。
|
|
credAccessor, ok := wsSvc.(workspace.CredentialAccessor)
|
|
if !ok {
|
|
return nil, fmt.Errorf("workspace service does not implement CredentialAccessor")
|
|
}
|
|
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
|
|
return credAccessor.LoadDecryptedCredential(ctx, wsID)
|
|
}
|
|
|
|
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, cfg.Workspace.CloneTimeout)
|
|
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
|
|
setter.SetScheduler(cloneRunner)
|
|
}
|
|
|
|
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, wsDataDir)
|
|
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
|
|
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
|
|
// ===== change-request (PR) + VCS provider 装配 =====
|
|
// vcs.Provider 是 git-host REST 客户端(v1 仅 Gitea);ParseRepoRef 用 Host()
|
|
// 做主机白名单校验。crSvc 复用 pa(owner+admin 写鉴权)、wsRepo(GitRemoteURL
|
|
// -> RepoRef);用 wrappedAudit 让经 MCP 调用时自动打 via_mcp 标记(与 PM
|
|
// service 一致)。合并网关:review_verdict 必须为 approved(审批仅 Web 暴露,
|
|
// agent 无法自批自合)。
|
|
vcsProvider := vcs.NewGiteaProvider(cfg.VCS.Gitea.BaseURL, cfg.VCS.Gitea.Token, cfg.VCS.Gitea.Timeout)
|
|
// crRepo 已在上方 project 装配处提前构造(供 Done 网关复用),此处直接复用。
|
|
crSvc := changerequest.NewService(crRepo, changeRequestWorkspaceAdapter{repo: wsRepo}, wrappedAudit, vcsProvider, pa, changerequest.Config{
|
|
RequireCIPass: cfg.VCS.RequireCIPass,
|
|
MergeMethod: cfg.VCS.Gitea.MergeMethod,
|
|
Host: vcsProvider.Host(),
|
|
})
|
|
changerequest.NewHandler(crSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
|
|
// ===== chat 模块装配 =====
|
|
// 依赖图:
|
|
// pgx.Pool ─ chat.Repository ─┐
|
|
// ├─ EndpointLoader ─ llm.Registry ─┐
|
|
// crypto.Encryptor ───────────┘ │
|
|
// │
|
|
// storage.LocalFS ─ Storage ──┐ │
|
|
// ├─ MessageService ─ Handler ──────┤
|
|
// notify.Dispatcher ─ StreamerHub ─ ConversationService ─ Hub ───┘
|
|
//
|
|
// 启动期把卡死的 pending 消息复位为 error,避免行永远卡住。
|
|
chatRepo := chat.NewRepository(pool)
|
|
if err := chatRepo.MarkPendingAsErrorOnStartup(ctx); err != nil {
|
|
log.Warn("mark chat pending messages as error on startup", "err", err.Error())
|
|
}
|
|
|
|
endpointLoader := chat.NewEndpointLoader(chatRepo, enc)
|
|
llmRegistry := llm.NewRegistry(endpointLoader)
|
|
|
|
var attachStorage storage.Storage
|
|
switch cfg.Storage.Driver {
|
|
case "localfs", "":
|
|
s, err := storage.NewLocalFS(cfg.Storage.LocalFS.BasePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage localfs: %w", err)
|
|
}
|
|
attachStorage = s
|
|
default:
|
|
return nil, fmt.Errorf("unsupported storage driver %q", cfg.Storage.Driver)
|
|
}
|
|
|
|
// safety_margin_pct 配置为百分比(5.0 表示 5%),构造器期望小数(0.05),故 /100。
|
|
safetyFraction := cfg.Chat.History.SafetyMarginPct / 100
|
|
retention := time.Duration(cfg.Chat.Stream.RingBufferRetentionSeconds) * time.Second
|
|
|
|
chatEpSvc := chat.NewEndpointService(chatRepo, enc, llmRegistry, auditRec, log)
|
|
chatTplSvc := chat.NewTemplateService(chatRepo, auditRec, log)
|
|
chatConvSvc := chat.NewConversationService(chatRepo, llmRegistry, auditRec, log)
|
|
chatHub := chat.NewStreamerHub(chatRepo, llmRegistry, notifyDispatcher, auditRec,
|
|
chatConvSvc, log, retention, safetyFraction)
|
|
chatMsgSvc := chat.NewMessageService(chatRepo, attachStorage, chatHub, auditRec, log,
|
|
chat.MessageServiceConfig{
|
|
MimeWhitelist: cfg.Chat.Attachment.MimeWhitelist,
|
|
MaxFileBytes: cfg.Chat.Attachment.MaxFileSizeMB << 20,
|
|
MaxMsgBytes: cfg.Chat.Attachment.MaxMessageSizeMB << 20,
|
|
SafetyPct: safetyFraction,
|
|
},
|
|
chatContextReaderAdapter{projectRepo: projectRepo, composer: briefComposer})
|
|
chatUsageSvc := chat.NewUsageService(chatRepo)
|
|
chatUploader := chat.NewUploader(chatRepo, attachStorage,
|
|
cfg.Chat.Attachment.MimeWhitelist,
|
|
cfg.Chat.Attachment.MaxFileSizeMB<<20,
|
|
)
|
|
chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc,
|
|
chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
|
|
// ===== MCP 模块装配(第一段:token/限流)=====
|
|
// Server 本体的装配在 ACP / run 模块之后(ServerDeps 依赖 acp bridge 与 runSvc),
|
|
// 这里只构造 ACP reaper / supervisor 需要的 token service。
|
|
mcpRepo := mcp.NewPostgresRepository(pool)
|
|
mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // mcp 自身 audit 不需要 via_mcp 标记
|
|
mcpRateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{
|
|
PerTokenPerMinute: cfg.MCP.RateLimit.PerTokenPerMinute,
|
|
GlobalPerMinute: cfg.MCP.RateLimit.GlobalPerMinute,
|
|
}, nil)
|
|
|
|
// ===== ACP module wiring (Part 2: full SessionService + Supervisor) =====
|
|
acpRepo := acp.NewPostgresRepository(pool)
|
|
akSvc := acp.NewAgentKindService(acpRepo, enc, auditRec)
|
|
|
|
// Phase 1: startup reaper — recover sessions interrupted by previous crash.
|
|
// ResetStuckSessionsOnRestart marks all 'starting'/'running' sessions as 'crashed'
|
|
// and returns them; we release their worktrees and notify owners.
|
|
abortedSessions, rerr := acpRepo.ResetStuckSessionsOnRestart(ctx)
|
|
if rerr != nil {
|
|
log.Warn("acp.startup_reaper.reset_failed", "err", rerr.Error())
|
|
} else {
|
|
for _, sess := range abortedSessions {
|
|
if sess.IsMainWorktree {
|
|
if _, rerr := acpRepo.ReleaseMainWorktree(ctx, sess.WorkspaceID, sess.ID); rerr != nil {
|
|
log.Warn("acp.startup_reaper.release_main_failed",
|
|
"session_id", sess.ID, "err", rerr.Error())
|
|
}
|
|
} else {
|
|
if _, rerr := wtSvc.ReleaseByHolder(ctx, "session:"+sess.ID.String()); rerr != nil {
|
|
log.Warn("acp.startup_reaper.release_subwt_failed",
|
|
"session_id", sess.ID, "err", rerr.Error())
|
|
}
|
|
}
|
|
// 该会话已被标 crashed,其挂起的权限请求也应一并标 expired,避免悬挂。
|
|
if _, rerr := acpRepo.ExpirePendingPermissionRequestsBySession(ctx, sess.ID); rerr != nil {
|
|
log.Warn("acp.startup_reaper.expire_permissions_failed",
|
|
"session_id", sess.ID, "err", rerr.Error())
|
|
}
|
|
uid := sess.UserID
|
|
if rerr := auditRec.Record(ctx, audit.Entry{
|
|
UserID: &uid, Action: "acp.session.aborted_on_restart",
|
|
TargetType: "acp_session", TargetID: sess.ID.String(),
|
|
Metadata: map[string]any{
|
|
"agent_kind_id": sess.AgentKindID.String(),
|
|
"branch": sess.Branch,
|
|
},
|
|
}); rerr != nil {
|
|
log.Warn("acp.startup_reaper.audit_failed",
|
|
"session_id", sess.ID, "err", rerr.Error())
|
|
}
|
|
if derr := notifyDispatcher.Dispatch(ctx, notify.Message{
|
|
UserID: sess.UserID,
|
|
Topic: "acp.session_crashed",
|
|
Severity: notify.SeverityWarning,
|
|
Title: "ACP session terminated by server restart",
|
|
Body: "Your session was interrupted while the server restarted.",
|
|
Metadata: map[string]any{
|
|
"session_id": sess.ID.String(),
|
|
"branch": sess.Branch,
|
|
},
|
|
}); derr != nil {
|
|
log.Warn("acp.startup_reaper.notify_failed",
|
|
"session_id", sess.ID, "err", derr.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 1.5 (post-ACP-reaper): revoke MCP system tokens still bound to crashed/exited
|
|
// sessions. spec §6.4 — must run AFTER ACP reaper so sessions are already marked crashed.
|
|
revokedTokenIDs, rerr := mcpRepo.ReapStaleSystemTokens(ctx)
|
|
if rerr != nil {
|
|
log.Warn("mcp.startup_reaper.failed", "err", rerr.Error())
|
|
} else if len(revokedTokenIDs) > 0 {
|
|
log.Info("mcp.startup_reaper.revoked", "count", len(revokedTokenIDs))
|
|
_ = auditRec.Record(ctx, audit.Entry{
|
|
Action: "mcp.token.expire_on_restart",
|
|
TargetType: "mcp_token",
|
|
TargetID: "(batch)",
|
|
Metadata: map[string]any{
|
|
"count": len(revokedTokenIDs),
|
|
"token_ids": func() []string {
|
|
out := make([]string, 0, len(revokedTokenIDs))
|
|
for _, id := range revokedTokenIDs {
|
|
out = append(out, id.String())
|
|
}
|
|
return out
|
|
}(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Phase 2: build supervisor + session service.
|
|
acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo}
|
|
acpSup := acp.NewSupervisor(
|
|
acpRepo, auditRec, notifyDispatcher,
|
|
wtSvc, wsRepo, mcpTokenSvc, enc,
|
|
acp.SupervisorConfig{
|
|
SpawnTimeout: cfg.Acp.SpawnTimeout,
|
|
KillGrace: cfg.Acp.KillGrace,
|
|
StderrBufferLines: cfg.Acp.StderrBufferLines,
|
|
StderrTailBytes: cfg.Acp.StderrTailBytes,
|
|
EventMaxPayload: cfg.Acp.EventMaxPayload,
|
|
EventTruncateField: cfg.Acp.EventTruncateField,
|
|
ShutdownGrace: cfg.Acp.ShutdownGrace,
|
|
AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"),
|
|
DefaultProjectBudgetUSD: cfg.Acp.DefaultProjectBudgetUSD,
|
|
// per-session 沙箱:factory 据 mode 选实现(非 Linux / none 时为 no-op)。
|
|
Sandbox: sandbox.New(sandbox.Mode(cfg.Acp.Sandbox.Mode)),
|
|
SandboxMode: sandbox.Mode(cfg.Acp.Sandbox.Mode),
|
|
SandboxBaseUID: cfg.Acp.Sandbox.BaseUID,
|
|
DataRoot: cfg.DataDir,
|
|
EgressProxyURL: cfg.Acp.Sandbox.ProxyURL,
|
|
SandboxRlimits: sandbox.Limits{
|
|
AddressSpaceBytes: cfg.Acp.Sandbox.AddressSpaceBytes,
|
|
NProc: cfg.Acp.Sandbox.NProc,
|
|
CPUSeconds: cfg.Acp.Sandbox.CPUSeconds,
|
|
PIDs: cfg.Acp.Sandbox.PIDs,
|
|
DiskBytes: cfg.Acp.Sandbox.DiskBytes,
|
|
},
|
|
},
|
|
log,
|
|
)
|
|
// 成本核算:注入 model 价格查询(acp → chat.ComputeCost),并给 agent-kind
|
|
// 服务注入 model 校验器。两者均装配期回填,避免 acp 构造期依赖 chat。
|
|
acpSup.SetModelPriceLookup(acpModelPriceAdapter{repo: chatRepo})
|
|
if appMetrics != nil {
|
|
acpSup.SetMetrics(appMetrics)
|
|
}
|
|
if akSetter, ok := akSvc.(interface {
|
|
SetModelValidator(acp.ModelValidator)
|
|
}); ok {
|
|
akSetter.SetModelValidator(acpModelValidatorAdapter{repo: chatRepo})
|
|
}
|
|
// 回合完成检测事件总线:进程内 pub-sub,供未来编排层订阅。注册一个日志订阅者
|
|
// 把总线端到端跑通(编排层尚未实现)。
|
|
turnBus := acp.NewTurnBus(log)
|
|
turnBus.Subscribe("log", func(_ context.Context, ev acp.TurnEvent) {
|
|
log.Info("acp.turn_event",
|
|
"kind", string(ev.Kind),
|
|
"session_id", ev.SessionID,
|
|
"turn_index", ev.TurnIndex,
|
|
"stop_reason", string(ev.StopReason),
|
|
"raw_stop_reason", ev.RawStopReason)
|
|
})
|
|
acpSup.SetTurnBus(turnBus)
|
|
|
|
// 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService /
|
|
// SetRelayLocator),再注入 handler。
|
|
acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log)
|
|
// HITL:待审权限通知(关闭页面的用户也能被提醒去 /approvals)+ 决策计数。
|
|
acpPermSvc.SetNotifier(notifyDispatcher)
|
|
if appMetrics != nil {
|
|
acpPermSvc.SetMetrics(appMetrics)
|
|
}
|
|
acpSup.SetPermissionService(acpPermSvc)
|
|
acpPermSvc.SetRelayLocator(acpSup)
|
|
// HITL:允许任意 project maintainer(非仅会话 owner)处理待审权限请求。
|
|
// 复用成员感知的 projectAccessAdapter(与 project_service ACL 一致)。
|
|
acpPermSvc.SetProjectMaintainer(projectMaintainerAdapter{pa: pa})
|
|
sessSvc := acp.NewSessionService(
|
|
acpRepo, wsSvc, wtSvc, wsRepo,
|
|
acpProjAccess, acpArtifactAccessAdapter{projectRepo: projectRepo}, briefComposer, acpSup, auditRec,
|
|
acp.SessionServiceConfig{
|
|
MaxActiveGlobal: cfg.Acp.MaxActiveGlobal,
|
|
MaxActivePerUser: cfg.Acp.MaxActivePerUser,
|
|
SystemTokenTTL: cfg.MCP.SystemTokenTTL,
|
|
MCPPublicURL: cfg.MCP.PublicURL,
|
|
BriefTokenBudget: cfg.Acp.BriefTokenBudget,
|
|
},
|
|
mcpTokenSvc,
|
|
)
|
|
acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, acpPermSvc, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{
|
|
PingInterval: cfg.Acp.WSPingInterval,
|
|
PongTimeout: cfg.Acp.WSPongTimeout,
|
|
SendBuffer: cfg.Acp.WSSendBuffer,
|
|
}).Mount(r)
|
|
|
|
// ===== orchestrator/planner module wiring =====
|
|
// jobsRepo 提前构造(无 ACP 依赖):编排器 StartRun/redrive 用它入队 orchestrator.step。
|
|
jobsRepo := jobs.NewPostgresRepository(pool)
|
|
|
|
// ===== code index (pgvector RAG) + project memory + auto-docs wiring =====
|
|
// EmbeddingSelector 解析配置的 embedding endpoint+model(OpenAI/Gemini)。无
|
|
// endpoint 配置时返回 ErrEmbeddingDisabled:search_code/reindex 报 typed 错误,
|
|
// memory_search 降级关键字回退。Anthropic-only 部署只有关键字能力。
|
|
embedSel := &embeddingSelector{registry: llmRegistry, cfg: cfg.CodeIndex}
|
|
wsLocator := &workspaceLocatorAdapter{repo: wsRepo}
|
|
|
|
codeIndexRepo := codeindex.NewPostgresRepository(pool)
|
|
projectMemoryRepo := projectmemory.NewPostgresRepository(pool)
|
|
docsRepo := docs.NewPostgresRepository(pool)
|
|
|
|
chunker := codeindex.NewChunker()
|
|
if cfg.CodeIndex.ChunkWindowLines > 0 {
|
|
chunker.WindowLines = cfg.CodeIndex.ChunkWindowLines
|
|
}
|
|
if cfg.CodeIndex.ChunkOverlapLines > 0 {
|
|
chunker.OverlapLines = cfg.CodeIndex.ChunkOverlapLines
|
|
}
|
|
if cfg.CodeIndex.MaxFileBytes > 0 {
|
|
chunker.MaxFileBytes = cfg.CodeIndex.MaxFileBytes
|
|
}
|
|
|
|
var codeIndexSvc codeindex.Service
|
|
var projectMemorySvc projectmemory.Service
|
|
if cfg.CodeIndex.Enabled {
|
|
codeIndexSvc = codeindex.NewService(codeIndexRepo, wsLocator, gitr, embedSel, jobsRepo, cfg.CodeIndex.SearchTopKCap)
|
|
}
|
|
// project memory 始终可用(embedder 缺失时只是无向量、走关键字回退)。
|
|
projectMemorySvc = projectmemory.NewService(projectMemoryRepo, embedSel, log)
|
|
|
|
// codeindex.build 处理器(失败时广播给 admin)。
|
|
codeIndexFailureNotify := func(nctx context.Context, run *codeindex.Run, cause error) {
|
|
admins, lerr := userSvc.ListAdmins(nctx)
|
|
if lerr != nil {
|
|
return
|
|
}
|
|
for _, a := range admins {
|
|
_ = notifyDispatcher.Dispatch(nctx, notify.Message{
|
|
UserID: a.ID, Topic: "codeindex.build_failed", Severity: notify.SeverityError,
|
|
Title: "Code index build failed",
|
|
Body: fmt.Sprintf("workspace=%s commit=%s: %v", run.WorkspaceID, run.CommitSHA, cause),
|
|
})
|
|
}
|
|
}
|
|
codeIndexBuildRunner := codeindex.NewBuildRunner(codeIndexRepo, wsLocator, gitr, embedSel, chunker, codeIndexFailureNotify, log)
|
|
|
|
// docs.commit_summary 处理器 + service。Summarizer 复用 chat 的 title-generator
|
|
// model 走 llm.Stream 收集 markdown。docs 默认关闭以省 LLM 成本。
|
|
docsSummarizer := &commitSummarizer{registry: llmRegistry, chatRepo: chatRepo, log: log}
|
|
docsLocator := &docsLocatorAdapter{repo: wsRepo}
|
|
docsSummaryRunner := docs.NewSummaryRunner(docsRepo, docsLocator, gitr, docsSummarizer, log)
|
|
docsSvc := docs.NewService(jobsRepo, cfg.Docs.Enabled)
|
|
|
|
// Hooks(functional setters,避免 workspace/acp → codeindex/docs 的 import 环)。
|
|
if codeIndexSvc != nil {
|
|
indexHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) error {
|
|
// Sync 路径(worktreeID==nil)先把旧 run 标 stale,再按新 HEAD 入队。
|
|
if worktreeID == nil {
|
|
if serr := codeIndexSvc.MarkStale(hctx, hwsID); serr != nil {
|
|
log.Warn("codeindex.mark_stale_failed", "workspace_id", hwsID, "err", serr.Error())
|
|
}
|
|
}
|
|
_, err := codeIndexSvc.EnqueueBuild(hctx, hwsID, worktreeID, commitSHA)
|
|
if errors.Is(err, codeindex.ErrEmbeddingDisabled) {
|
|
return nil // feature off; not an error
|
|
}
|
|
return err
|
|
}
|
|
if setter, ok := wtSvc.(interface {
|
|
SetIndexBuildHook(workspace.IndexBuildHook)
|
|
}); ok {
|
|
setter.SetIndexBuildHook(indexHook)
|
|
}
|
|
if setter, ok := wsSvc.(interface {
|
|
SetIndexBuildHook(workspace.IndexBuildHook)
|
|
}); ok {
|
|
setter.SetIndexBuildHook(indexHook)
|
|
}
|
|
}
|
|
if cfg.Docs.Enabled {
|
|
summaryHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error {
|
|
return docsSvc.EnqueueCommitSummary(hctx, hwsID, worktreeID, branch, commitSHA)
|
|
}
|
|
if setter, ok := gopSvc.(interface {
|
|
SetCommitSummaryHook(workspace.CommitSummaryHook)
|
|
}); ok {
|
|
setter.SetCommitSummaryHook(summaryHook)
|
|
}
|
|
}
|
|
// AGENTS.md seeding:把 project memory 渲染进 worktree(acpSup 在下方构造,故此处
|
|
// 仅保留 renderer 闭包,acpSup 构造后再注入)。
|
|
agentsMDRenderer := func(rctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) {
|
|
return projectMemorySvc.RenderAgentsMD(rctx, projectID, wsID)
|
|
}
|
|
acpSup.SetAgentsMDRenderer(agentsMDRenderer)
|
|
|
|
// ===== 安全:master-key 轮换 + re-encrypt job + admin 端点 =====
|
|
// re-encrypt store 扫描每张加密列表、逐行重封到新版本;handler 注册进 jobs map。
|
|
// rotate-key 端点 bump crypto_keys active 版本并入队 secret.reencrypt job。
|
|
reencryptStore := handlers.NewPostgresReencryptStore(pool)
|
|
secretReencryptHandler := handlers.NewSecretReencryptHandler(reencryptStore, keyedEnc, log)
|
|
security.NewHandler(cryptoKeyStore, jobsRepo, reencryptStore, userSvc,
|
|
userAdminAdapter{svc: userSvc}, auditRec).Mount(r)
|
|
|
|
orchRepo := orchestrator.NewPostgresRepository(pool)
|
|
// 阶段→默认 agent-kind:把 config 里的"阶段名→agent-kind 名"映射按名解析为 UUID。
|
|
orchDefaults := map[project.Phase]uuid.UUID{}
|
|
for phaseName, kindName := range cfg.Orchestrator.PhaseAgentKinds {
|
|
if k, kerr := acpRepo.GetAgentKindByName(ctx, kindName); kerr == nil && k != nil {
|
|
orchDefaults[project.Phase(phaseName)] = k.ID
|
|
} else {
|
|
log.Warn("orchestrator.default_agent_kind_unresolved",
|
|
"phase", phaseName, "agent_kind", kindName)
|
|
}
|
|
}
|
|
// 不变量:编排器步骤跑在 jobs worker 上,单个 handler 超时 = lease_timeout*9/10。
|
|
// turn_timeout 必须严格小于它,否则一次完整 agent 回合会被中途 reap 并重复执行。
|
|
// 启动期快速失败,把配置错误暴露在部署时而非运行时。
|
|
if cfg.Orchestrator.Enabled && cfg.Jobs.JobReaper.LeaseTimeout > 0 {
|
|
jobHandlerTimeout := cfg.Jobs.JobReaper.LeaseTimeout - cfg.Jobs.JobReaper.LeaseTimeout/10
|
|
if cfg.Orchestrator.TurnTimeout >= jobHandlerTimeout {
|
|
return nil, fmt.Errorf("orchestrator.turn_timeout (%s) must be < jobs handler timeout (%s = job_reaper.lease_timeout*9/10); raise jobs.job_reaper.lease_timeout",
|
|
cfg.Orchestrator.TurnTimeout, jobHandlerTimeout)
|
|
}
|
|
}
|
|
orchCfg := orchestrator.Config{
|
|
MaxAttemptsPerPhase: cfg.Orchestrator.MaxAttemptsPerPhase,
|
|
TurnTimeout: cfg.Orchestrator.TurnTimeout,
|
|
MaxDepth: cfg.Orchestrator.MaxDepth,
|
|
FanOutLimit: cfg.Orchestrator.FanOutLimit,
|
|
}
|
|
orchPM := orchPMAdapter{repo: projectRepo, reqs: requirementSvc, adminLookup: userAdminAdapter{svc: userSvc}}
|
|
orchSvc := orchestrator.NewService(orchestrator.ServiceDeps{
|
|
Repo: orchRepo,
|
|
PM: orchPM,
|
|
ACL: orchACLAdapter{pa: projectAccessAdapter{p: projectSvc}},
|
|
Jobs: jobsRepo,
|
|
Sessions: orchSessionTermAdapter{sess: sessSvc},
|
|
Defaults: orchDefaults,
|
|
Config: orchCfg,
|
|
Audit: orchAuditAdapter{rec: auditRec},
|
|
Log: log,
|
|
})
|
|
// sessSvc 同时实现 acp.TurnRunner(SendPromptAndWait/ResumeSession)。
|
|
orchTurnRunner, _ := sessSvc.(acp.TurnRunner)
|
|
orchStepHandler := orchestrator.NewStepHandler(orchestrator.StepHandlerDeps{
|
|
Repo: orchRepo,
|
|
Runner: orchestrator.NewRunner(sessSvc, orchTurnRunner, orchACPLookupAdapter{repo: acpRepo}, cfg.Orchestrator.TurnTimeout),
|
|
Gate: orchestrator.NewV1Gate(orchGateArtifactAdapter{repo: projectRepo}, orchGateIssueAdapter{repo: projectRepo}),
|
|
PM: orchPM,
|
|
Enqueue: orchSvc.(orchestrator.StepEnqueuer),
|
|
DefaultAgentKind: orchDefaults,
|
|
Config: orchCfg,
|
|
Audit: orchAuditAdapter{rec: auditRec},
|
|
Log: log,
|
|
})
|
|
// 崩溃的编排 session → 重新入队对应 step(带短延迟)。注入为 func 避免 acp→orchestrator 循环。
|
|
acpSup.SetOrchestratorRedrive(func(rctx context.Context, stepID uuid.UUID, reason string) {
|
|
orchSvc.EnqueueStepRedrive(rctx, stepID, reason)
|
|
})
|
|
// 启动恢复:把活跃 run 下卡住的 step 重置为 pending 并重新入队(须在 ACP reaper 之后,
|
|
// 使 resume 看到已 crashed 的 session 与已释放的 worktree)。
|
|
if stuckSteps, serr := orchRepo.ResetStuckStepsOnRestart(ctx); serr != nil {
|
|
log.Warn("orchestrator.startup_reaper.reset_failed", "err", serr.Error())
|
|
} else {
|
|
for _, st := range stuckSteps {
|
|
if eerr := orchSvc.(orchestrator.StepEnqueuer).EnqueueStep(ctx, st.RunID, st.ID, time.Now().UTC()); eerr != nil {
|
|
log.Warn("orchestrator.startup_reaper.enqueue_failed", "step_id", st.ID, "err", eerr.Error())
|
|
}
|
|
}
|
|
}
|
|
if cfg.Orchestrator.Enabled {
|
|
orchestrator.NewHandler(orchSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
|
}
|
|
|
|
// ===== task-decomposition parallel scheduler(autonomy roadmap §10)=====
|
|
// 服务端调度器:拓扑选取就绪(未阻塞/open/叶子/无活跃 session)子任务,扇出到独立
|
|
// worktree 上的并行 ACP session。以项目 owner 特权身份调用 SessionService.Create,
|
|
// 故不受 checkNotSystemToken 限制。注册为 jobs 周期 RunnerFn(单实例、单 tick,跨重启存活)。
|
|
orchScheduler := orchestrator.NewScheduler(orchestrator.SchedulerDeps{
|
|
Projects: schedulerReadySourceAdapter{repo: projectRepo},
|
|
Sessions: schedulerSessionStarterAdapter{
|
|
sess: sessSvc,
|
|
projectRepo: projectRepo,
|
|
wsRepo: wsRepo,
|
|
defaultAgentKind: func() (uuid.UUID, bool) {
|
|
if id, ok := orchDefaults[project.PhaseImplementing]; ok && id != uuid.Nil {
|
|
return id, true
|
|
}
|
|
return uuid.Nil, false
|
|
},
|
|
},
|
|
Notify: schedulerNotifyAdapter{dispatcher: notifyDispatcher, projectRepo: projectRepo},
|
|
Log: log,
|
|
MaxFanoutPerTick: cfg.Orchestrator.Scheduler.MaxFanoutPerTick,
|
|
MaxConcurrentPerProject: cfg.Orchestrator.Scheduler.MaxConcurrentPerProject,
|
|
})
|
|
|
|
// ===== run profiles module wiring =====
|
|
// 进程托管:每个 workspace 的 run profile 作为长驻进程在 main_path 拉起。
|
|
// 复用 workspace.ProjectAccess(pa) 做 owner+admin 写鉴权。WS 心跳沿用 acp 的
|
|
// 全局默认值(同类参数,避免新增配置面)。
|
|
runRepo := runmod.NewPostgresRepository(pool)
|
|
runSup := runmod.NewSupervisor(runRepo, runmod.SupervisorConfig{
|
|
KillGrace: cfg.Run.KillGrace,
|
|
OutBufferLines: cfg.Run.OutBufferLines,
|
|
OutTailBytes: cfg.Run.OutTailBytes,
|
|
WSSendBuffer: cfg.Run.WSSendBuffer,
|
|
ShutdownGrace: cfg.Run.ShutdownGrace,
|
|
}, log)
|
|
runSvc := runmod.NewService(runRepo, wsRepo, runSup, pa, auditRec, enc, runmod.ServiceConfig{
|
|
EnvWhitelist: cfg.Run.EnvWhitelist,
|
|
KillGrace: cfg.Run.KillGrace,
|
|
OutTailBytes: cfg.Run.OutTailBytes,
|
|
}, log)
|
|
// 一次性 exec(run_command / run_tests):agent 自验证闭环。无状态同步,无需
|
|
// ShutdownAll 注册——RunOnce 受请求 ctx + 自身超时约束。
|
|
execSvc := runmod.NewExecService(wsRepo, wtSvc, pa, auditRec, enc, runmod.ExecConfig{
|
|
EnvWhitelist: cfg.Run.EnvWhitelist,
|
|
DefaultTimeout: cfg.Run.Exec.DefaultTimeout,
|
|
MaxTimeout: cfg.Run.Exec.MaxTimeout,
|
|
MaxOutputBytes: cfg.Run.Exec.MaxOutputBytes,
|
|
KillGrace: cfg.Run.KillGrace,
|
|
AllowedCommands: cfg.Run.Exec.AllowedCommands,
|
|
}, log)
|
|
if cfg.Run.Enabled {
|
|
runmod.NewHandler(runSvc, userSvc, userAdminAdapter{svc: userSvc}, runmod.WSConfig{
|
|
PingInterval: cfg.Acp.WSPingInterval,
|
|
PongTimeout: cfg.Acp.WSPongTimeout,
|
|
SendBuffer: cfg.Run.WSSendBuffer,
|
|
}).Mount(r)
|
|
}
|
|
|
|
// ===== admin terminal module wiring =====
|
|
// 仅限管理员的网页端交互式终端:在宿主进程(部署时即 runtime 容器)内开 shell,
|
|
// 主要用于运维(容器内 npm i -g 安装 ACP 客户端 CLI)。会话纯内存、不落库。
|
|
// WS 心跳沿用 acp 全局默认值(同类参数,避免新增配置面)。
|
|
termSup := terminal.NewSupervisor(terminal.SupervisorConfig{
|
|
Shell: cfg.Terminal.Shell,
|
|
MaxSessions: cfg.Terminal.MaxSessions,
|
|
KillGrace: cfg.Terminal.KillGrace,
|
|
EnvWhitelist: cfg.Terminal.EnvWhitelist,
|
|
}, log)
|
|
if cfg.Terminal.Enabled {
|
|
terminal.NewHandler(termSup, userSvc, userAdminAdapter{svc: userSvc}, auditRec, cfg.Terminal.Shell, terminal.WSConfig{
|
|
PingInterval: cfg.Acp.WSPingInterval,
|
|
PongTimeout: cfg.Acp.WSPongTimeout,
|
|
}).Mount(r)
|
|
}
|
|
|
|
// ===== MCP 模块装配(第二段:Server 本体 + transports + admin CRUD)=====
|
|
mcpDeps := mcp.ServerDeps{
|
|
Caller: mcp.NewCallerResolver(userSvc),
|
|
Projects: projectSvc,
|
|
Reqs: requirementSvc,
|
|
Issues: issueSvc,
|
|
Artifacts: artifactSvc,
|
|
Workspaces: wsSvc,
|
|
Worktrees: wtSvc,
|
|
GitOps: gopSvc,
|
|
ChangeReqs: crSvc,
|
|
Templates: chatTplSvc,
|
|
Messages: chatMsgSvc,
|
|
Conversations: chatConvSvc,
|
|
Endpoints: chatEpSvc,
|
|
ACP: acp.NewMCPBridge(akSvc, sessSvc, acpPermSvc, acpRepo),
|
|
Runs: runSvc,
|
|
Execs: execSvc,
|
|
Orchestrator: orchestrator.NewMCPBridge(orchSvc),
|
|
CodeIndex: codeIndexSvc, // 可为 nil(code_index.enabled=false)
|
|
Memory: projectMemorySvc, // 始终非 nil(无 embedder 时关键字回退)
|
|
}
|
|
mcpServer := mcp.NewMCPServer(mcpDeps)
|
|
mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter)
|
|
mcp.MountAdmin(r, mcp.AdminHandlerDeps{
|
|
Tokens: mcpTokenSvc,
|
|
Users: userSvc,
|
|
})
|
|
|
|
// ===== jobs module wiring =====
|
|
// jobsRepo 已在 orchestrator 装配块提前构造(见上)。
|
|
|
|
webhookBackoff := []time.Duration{
|
|
30 * time.Second, 2 * time.Minute, 8 * time.Minute, 30 * time.Minute, 2 * time.Hour,
|
|
}
|
|
|
|
webhookHandler := handlers.NewWebhookHandler(handlers.WebhookConfig{
|
|
URL: cfg.Notify.Webhook.URL,
|
|
Secret: cfg.Notify.Webhook.Secret,
|
|
Timeout: cfg.Notify.Webhook.Timeout,
|
|
}, clock.Real())
|
|
|
|
webhookNotifier := jobs.NewWebhookNotifier(jobsRepo, jobs.WebhookNotifierConfig{
|
|
Enabled: cfg.Notify.Webhook.Enabled,
|
|
Topics: cfg.Notify.Webhook.Topics,
|
|
Backoff: webhookBackoff,
|
|
}, clock.Real(), auditRec, log)
|
|
notifyDispatcher.Register(webhookNotifier)
|
|
|
|
dispAdapter := dispatcherAdapter{d: notifyDispatcher}
|
|
wsFetchRunner := runners.NewWorkspaceFetch(
|
|
workspaceFetchAdapter{repo: wsRepo},
|
|
workspaceFetcherAdapter{gitr: gitr, credLoader: credLoader},
|
|
dispAdapter, log)
|
|
wtPruneRunner := runners.NewWorktreePrune(
|
|
worktreePruneAdapter{wsRepo: wsRepo, pLookup: projectRepo, log: log},
|
|
gitr,
|
|
dispAdapter,
|
|
runners.WorktreePruneConfig{
|
|
IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold,
|
|
WarningLead: cfg.Jobs.WorktreePrune.WarningLead,
|
|
}, log, auditRec)
|
|
attCleanupRunner := runners.NewAttachmentCleanup(
|
|
chatAttachmentAdapter{repo: chatRepo},
|
|
attachStorage,
|
|
runners.AttachmentCleanupConfig{Retention: cfg.Jobs.AttachmentCleanup.Retention},
|
|
log)
|
|
jobReaperRunner := runners.NewJobReaper(jobsRepo, cfg.Jobs.JobReaper.LeaseTimeout, log)
|
|
jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log)
|
|
acpEventsPurgeRunner := runners.NewAcpEventsPurge(
|
|
acpEventsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log)
|
|
acpTurnsPurgeRunner := runners.NewAcpTurnsPurge(
|
|
acpTurnsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log)
|
|
mcpTokensPurgeRunner := runners.NewMCPTokensPurge(
|
|
mcpTokensPurgeAdapter{repo: mcpRepo}, cfg.Jobs.MCPTokensPurge.Retention, log)
|
|
acpSessionReaperRunner := runners.NewAcpSessionReaper(
|
|
acpSessionReaperAdapter{repo: acpRepo}, acpSup, notifyDispatcher,
|
|
runners.AcpSessionReaperConfig{
|
|
WallClockTimeout: cfg.Jobs.AcpSessionReaper.WallClockTimeout,
|
|
IdleTimeout: cfg.Jobs.AcpSessionReaper.IdleTimeout,
|
|
KillGrace: cfg.Acp.KillGrace,
|
|
}, log)
|
|
auditRetentionRunner := runners.NewAuditRetention(auditReader, cfg.Jobs.AuditRetention.Retention, log)
|
|
|
|
deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) {
|
|
if appMetrics != nil {
|
|
appMetrics.RecordDeadLetter(string(job.Type))
|
|
}
|
|
errMsg := herr.Error()
|
|
if len(errMsg) > 500 {
|
|
errMsg = errMsg[:500]
|
|
}
|
|
if rerr := auditRec.Record(ctx, audit.Entry{
|
|
Action: "job.dead_letter",
|
|
TargetType: "job",
|
|
TargetID: job.ID.String(),
|
|
Metadata: map[string]any{
|
|
"type": string(job.Type),
|
|
"attempts": job.Attempts,
|
|
"err": errMsg,
|
|
},
|
|
}); rerr != nil {
|
|
log.Warn("dead_letter.audit_failed", "err", rerr.Error())
|
|
}
|
|
admins, err := userSvc.ListAdmins(ctx)
|
|
if err != nil {
|
|
log.Error("dead_letter.list_admins_failed", "err", err.Error())
|
|
return
|
|
}
|
|
for _, a := range admins {
|
|
if derr := notifyDispatcher.Dispatch(ctx, notify.Message{
|
|
UserID: a.ID,
|
|
Topic: "webhook.dead_letter",
|
|
Severity: notify.SeverityError,
|
|
Title: "Webhook delivery failed permanently",
|
|
Body: errMsg,
|
|
Metadata: map[string]any{
|
|
"job_id": job.ID.String(),
|
|
"job_type": string(job.Type),
|
|
},
|
|
CreatedAt: clock.Real().Now().UTC(),
|
|
}); derr != nil {
|
|
log.Warn("dead_letter.dispatch_failed", "admin_id", a.ID, "err", derr.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
jobsModule := jobs.NewModule(jobs.Config{
|
|
Enabled: cfg.Jobs.Enabled,
|
|
Workers: cfg.Jobs.Workers,
|
|
ShutdownGrace: cfg.Jobs.ShutdownGrace,
|
|
WorkspaceFetch: jobs.RunnerConfig{
|
|
Enabled: cfg.Jobs.WorkspaceFetch.Enabled,
|
|
Interval: cfg.Jobs.WorkspaceFetch.Interval,
|
|
},
|
|
WorktreePrune: jobs.WorktreePruneConfig{
|
|
Enabled: cfg.Jobs.WorktreePrune.Enabled,
|
|
Interval: cfg.Jobs.WorktreePrune.Interval,
|
|
IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold,
|
|
WarningLead: cfg.Jobs.WorktreePrune.WarningLead,
|
|
},
|
|
AttachmentCleanup: jobs.AttachmentCleanupConfig{
|
|
Enabled: cfg.Jobs.AttachmentCleanup.Enabled,
|
|
Interval: cfg.Jobs.AttachmentCleanup.Interval,
|
|
Retention: cfg.Jobs.AttachmentCleanup.Retention,
|
|
},
|
|
JobReaper: jobs.JobReaperConfig{
|
|
Enabled: cfg.Jobs.JobReaper.Enabled,
|
|
Interval: cfg.Jobs.JobReaper.Interval,
|
|
LeaseTimeout: cfg.Jobs.JobReaper.LeaseTimeout,
|
|
},
|
|
JobPurge: jobs.JobPurgeConfig{
|
|
Enabled: cfg.Jobs.JobPurge.Enabled,
|
|
Interval: cfg.Jobs.JobPurge.Interval,
|
|
CompletedRetention: cfg.Jobs.JobPurge.CompletedRetention,
|
|
},
|
|
AcpEventsPurge: jobs.AcpEventsPurgeConfig{
|
|
Enabled: cfg.Jobs.AcpEventsPurge.Enabled,
|
|
Interval: cfg.Jobs.AcpEventsPurge.Interval,
|
|
Retention: cfg.Jobs.AcpEventsPurge.Retention,
|
|
},
|
|
MCPTokensPurge: jobs.MCPTokensPurgeConfig{
|
|
Enabled: cfg.Jobs.MCPTokensPurge.Enabled,
|
|
Interval: cfg.Jobs.MCPTokensPurge.Interval,
|
|
Retention: cfg.Jobs.MCPTokensPurge.Retention,
|
|
},
|
|
AcpSessionReaper: jobs.AcpSessionReaperConfig{
|
|
Enabled: cfg.Jobs.AcpSessionReaper.Enabled,
|
|
Interval: cfg.Jobs.AcpSessionReaper.Interval,
|
|
},
|
|
// 任务分解并行调度器的周期 tick(autonomy roadmap §10)。
|
|
OrchestratorSchedule: jobs.RunnerConfig{
|
|
Enabled: cfg.Orchestrator.Scheduler.Enabled,
|
|
Interval: cfg.Orchestrator.Scheduler.Interval,
|
|
},
|
|
// 审计日志保留期清理(autonomy roadmap §11)。
|
|
AuditRetention: jobs.RunnerConfig{
|
|
Enabled: cfg.Jobs.AuditRetention.Enabled,
|
|
Interval: cfg.Jobs.AuditRetention.Interval,
|
|
},
|
|
}, jobs.ModuleDeps{
|
|
Repo: jobsRepo,
|
|
Handlers: map[jobs.JobType]jobs.Handler{
|
|
jobs.TypeWebhookDeliver: webhookHandler,
|
|
orchestrator.JobTypeOrchestratorStep: orchStepHandler,
|
|
jobs.TypeSecretReencrypt: secretReencryptHandler,
|
|
codeindex.JobTypeBuild: codeIndexBuildRunner,
|
|
docs.JobTypeCommitSummary: docsSummaryRunner,
|
|
},
|
|
RunnerFns: map[string]jobs.RunnerFunc{
|
|
"workspace_fetch": wsFetchRunner.Run,
|
|
"worktree_prune": wtPruneRunner.Run,
|
|
"attachment_cleanup": attCleanupRunner.Run,
|
|
"job_reaper": jobReaperRunner.Run,
|
|
"job_purge": jobPurgeRunner.Run,
|
|
"acp_events_purge": acpEventsPurgeRunner.Run,
|
|
"acp_turns_purge": acpTurnsPurgeRunner.Run,
|
|
"mcp_tokens_purge": mcpTokensPurgeRunner.Run,
|
|
"acp_session_reaper": acpSessionReaperRunner.Run,
|
|
"orchestrator_schedule": orchScheduler.Run,
|
|
"audit_retention": auditRetentionRunner.Run,
|
|
},
|
|
Clock: clock.Real(),
|
|
Log: log,
|
|
WorkerBackoff: webhookBackoff,
|
|
OnDeadLetter: deadLetterFn,
|
|
})
|
|
|
|
// Start the module with a background context; cancellation is wired into
|
|
// App.Run's shutdown path via jobsCancel.
|
|
jobsCtx, jobsCancel := context.WithCancel(context.Background())
|
|
jobsModule.Start(jobsCtx)
|
|
|
|
if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
|
|
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
|
log.Warn("bootstrap admin failed", "err", err.Error())
|
|
} else if u != nil {
|
|
log.Info("bootstrap admin created", "email", u.Email)
|
|
}
|
|
}
|
|
|
|
// 注册 system 作用域的 prompt 模板为 MCP prompts(spec)。需在 bootstrap 之后
|
|
// 执行以保证存在 admin;失败仅 warn,不阻断启动。以第一个 admin 的视角加载模板。
|
|
if admins, err := userSvc.ListAdmins(ctx); err != nil {
|
|
log.Warn("mcp register_system_prompts: list admins failed", "err", err.Error())
|
|
} else if len(admins) > 0 {
|
|
if err := mcp.RegisterSystemPrompts(mcpServer, mcpDeps, admins[0].ID); err != nil {
|
|
log.Warn("mcp register_system_prompts failed", "err", err.Error())
|
|
}
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.HTTP.Addr,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
return &App{
|
|
cfg: cfg, log: log, pool: pool, server: srv,
|
|
dispatcher: notifyDispatcher,
|
|
jobs: jobsModule,
|
|
jobsCancel: jobsCancel,
|
|
acpSup: acpSup,
|
|
runSup: runSup,
|
|
termSup: termSup,
|
|
}, nil
|
|
}
|
|
|
|
// Run 启动 HTTP server 并阻塞直到 ctx 取消(Shutdown)或服务器报错。
|
|
// 关停路径上保证关闭 db pool;返回 nil 表示由 ctx 取消触发的正常关停。
|
|
func (a *App) Run(ctx context.Context) error {
|
|
a.log.Info("server starting", "addr", a.cfg.HTTP.Addr, "env", a.cfg.Env)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
var serveErr error
|
|
if a.cfg.HTTP.TLS.Enabled {
|
|
a.log.Info("server serving https", "cert", a.cfg.HTTP.TLS.CertFile)
|
|
serveErr = a.server.ListenAndServeTLS(a.cfg.HTTP.TLS.CertFile, a.cfg.HTTP.TLS.KeyFile)
|
|
} else {
|
|
serveErr = a.server.ListenAndServe()
|
|
}
|
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
errCh <- serveErr
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
a.log.Info("shutdown signal received, draining...")
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
shutErr := a.server.Shutdown(shutCtx)
|
|
if shutErr != nil {
|
|
a.log.Error("server shutdown", "err", shutErr.Error())
|
|
}
|
|
// Stop ACP subprocesses (kill child agents) BEFORE jobs/dispatcher drain so
|
|
// any final crash notifications dispatched by onExit can flow through.
|
|
if a.acpSup != nil {
|
|
a.acpSup.ShutdownAll(shutCtx)
|
|
}
|
|
// Stop run-profile subprocesses (整树 kill) on shutdown.
|
|
if a.runSup != nil {
|
|
a.runSup.ShutdownAll(shutCtx)
|
|
}
|
|
// Close admin terminal sessions (整树 kill shell + 子孙) on shutdown.
|
|
if a.termSup != nil {
|
|
a.termSup.CloseAll(shutCtx)
|
|
}
|
|
// Stop jobs module first so no in-flight worker can dispatch new
|
|
// notifications after we begin to drain the dispatcher.
|
|
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
|
a.jobsCancel()
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second)
|
|
a.jobs.Stop(stopCtx)
|
|
stopCancel()
|
|
}
|
|
// server 已停止接收新请求;等异步通知投递结束再关 pool。
|
|
// 用独立的 drainCtx:shutCtx 的 30s 预算已被 server.Shutdown 与
|
|
// jobs.Stop 的实际墙钟时间消耗,复用可能让 dispatcher 拿到已死 ctx
|
|
// 而立即返回,丢失在途的 webhook enqueue(DB 写)。
|
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer drainCancel()
|
|
if err := a.dispatcher.Shutdown(drainCtx); err != nil {
|
|
a.log.Warn("notify dispatcher drain", "err", err.Error())
|
|
}
|
|
a.pool.Close()
|
|
return shutErr
|
|
case err := <-errCh:
|
|
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if a.acpSup != nil {
|
|
a.acpSup.ShutdownAll(drainCtx)
|
|
}
|
|
if a.runSup != nil {
|
|
a.runSup.ShutdownAll(drainCtx)
|
|
}
|
|
if a.termSup != nil {
|
|
a.termSup.CloseAll(drainCtx)
|
|
}
|
|
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
|
a.jobsCancel()
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second)
|
|
a.jobs.Stop(stopCtx)
|
|
stopCancel()
|
|
}
|
|
_ = a.dispatcher.Shutdown(drainCtx)
|
|
a.pool.Close()
|
|
return err
|
|
}
|
|
}
|
|
|
|
// userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。
|
|
// 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。
|
|
// workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名),
|
|
// 因此本类型同时满足两者,可在两个 handler 复用。
|
|
// buildCryptoProvider 据 cfg.Crypto.Provider 选取 secret key provider。
|
|
// env:APP_MASTER_KEY 为 version 1(轮换时 APP_MASTER_KEY_V<n> 暂存新版本)。
|
|
// vault/kms:客户端尚未接入,退回 env provider 并 warn,保持启动不破(接入真实
|
|
// 客户端时把 DelegatingProvider 替换为对应实现即可)。
|
|
func buildCryptoProvider(cfg *config.Config, log *slog.Logger) crypto.Provider {
|
|
env := crypto.NewEnvProvider(cfg.MasterKey)
|
|
switch cfg.Crypto.Provider {
|
|
case "vault", "kms":
|
|
log.Warn("crypto.provider_not_implemented_fallback_env", "provider", cfg.Crypto.Provider)
|
|
return crypto.NewDelegatingProvider(env)
|
|
default:
|
|
return env
|
|
}
|
|
}
|
|
|
|
type userAdminAdapter struct{ svc user.Service }
|
|
|
|
func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
|
|
u, err := a.svc.Get(ctx, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return u.IsAdmin, nil
|
|
}
|
|
|
|
// changeRequestWorkspaceAdapter 把 workspace.Repository 桥接成
|
|
// changerequest.WorkspaceLookup(窄接口),让 change-request service 不依赖
|
|
// workspace service 全量,只取 project_id / remote_url / default_branch。
|
|
type changeRequestWorkspaceAdapter struct {
|
|
repo workspace.Repository
|
|
}
|
|
|
|
func (a changeRequestWorkspaceAdapter) GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (changerequest.WorkspaceMeta, error) {
|
|
ws, err := a.repo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return changerequest.WorkspaceMeta{}, err
|
|
}
|
|
return changerequest.WorkspaceMeta{
|
|
ProjectID: ws.ProjectID,
|
|
GitRemoteURL: ws.GitRemoteURL,
|
|
DefaultBranch: ws.DefaultBranch,
|
|
}, nil
|
|
}
|
|
|
|
// projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
|
|
// - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径
|
|
// - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限
|
|
//
|
|
// ACL 语义复刻 project 包内部 assertReadable / assertWritable:
|
|
//
|
|
// read private: owner + admin
|
|
// read internal: 任意已登录
|
|
// write *: owner + admin
|
|
//
|
|
// 注意:project.assertReadable 在 visibility=private 且非 owner+admin 时返回 NotFound
|
|
// 防探测;这里 ProjectService.Get/GetByID 已替我们兜住,到本适配层时 pj 一定可读。
|
|
type projectAccessAdapter struct {
|
|
p project.ProjectService
|
|
}
|
|
|
|
func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
|
|
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
|
|
if err != nil {
|
|
return uuid.Nil, false, false, err
|
|
}
|
|
role := a.roleFor(ctx, pj, callerID, isAdmin)
|
|
canRead, canWrite := projectACL(pj, callerID, isAdmin, role)
|
|
return pj.ID, canRead, canWrite, nil
|
|
}
|
|
|
|
func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
|
|
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
role := a.roleFor(ctx, pj, callerID, isAdmin)
|
|
canRead, canWrite := projectACL(pj, callerID, isAdmin, role)
|
|
return canRead, canWrite, nil
|
|
}
|
|
|
|
// roleFor 查 caller 在 pj 的成员角色,供 projectACL 叠加授权。owner/global-admin
|
|
// 隐式 RoleAdmin(与 project_service.memberRoleFor 保持一致);查询失败回退 RoleNone,
|
|
// 既有 owner/admin/internal 规则仍兜底。
|
|
func (a projectAccessAdapter) roleFor(ctx context.Context, pj *project.Project, callerID uuid.UUID, isAdmin bool) project.Role {
|
|
if isAdmin || pj.OwnerID == callerID {
|
|
return project.RoleAdmin
|
|
}
|
|
role, err := a.p.MemberRole(ctx, pj.ID, callerID)
|
|
if err != nil {
|
|
return project.RoleNone
|
|
}
|
|
return role
|
|
}
|
|
|
|
// projectACL 复刻 project 包内部 assertReadable/assertWritable 的语义,并叠加成员/
|
|
// 角色 ACL(autonomy roadmap §11)——两侧必须一致,否则 workspace/ACP/chat 作用域
|
|
// 会与 project service 鉴权分叉:
|
|
// - global-admin 永远可读可写
|
|
// - owner 可读可写(经迁移回填为 project-admin 行)
|
|
// - project 角色 admin/member:可写(叠加);viewer:仅可读(叠加)
|
|
// - internal 可见性:所有登录用户可读
|
|
//
|
|
// 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess
|
|
// 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。
|
|
func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool, role project.Role) (canRead, canWrite bool) {
|
|
owned := pj.OwnerID == callerID
|
|
canWrite = isAdmin || owned || role.CanWrite()
|
|
canRead = canWrite || pj.Visibility == project.VisibilityInternal || role.CanRead()
|
|
return canRead, canWrite
|
|
}
|
|
|
|
// projectMaintainerAdapter satisfies acp.ProjectMaintainer. It reuses the
|
|
// member-aware projectAccessAdapter so "who can resolve a permission request"
|
|
// stays consistent with project_service write ACL (owner/admin/member roles).
|
|
type projectMaintainerAdapter struct {
|
|
pa projectAccessAdapter
|
|
}
|
|
|
|
func (a projectMaintainerAdapter) CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
|
|
_, canWrite, err := a.pa.ResolveByID(ctx, callerID, isAdmin, projectID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return canWrite, nil
|
|
}
|
|
|
|
// acpProjectAccessAdapter satisfies acp.ProjectAccess. It uses workspace.Repository
|
|
// to resolve workspace → project, then project.Repository for issue/requirement.
|
|
type acpProjectAccessAdapter struct {
|
|
wsRepo workspace.Repository
|
|
projectRepo project.Repository
|
|
}
|
|
|
|
func (a acpProjectAccessAdapter) GetProjectByWorkspace(ctx context.Context, wsID uuid.UUID) (*project.Project, error) {
|
|
ws, err := a.wsRepo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.projectRepo.GetProjectByID(ctx, ws.ProjectID)
|
|
}
|
|
|
|
func (a acpProjectAccessAdapter) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Issue, error) {
|
|
return a.projectRepo.GetIssueByNumber(ctx, projectID, number)
|
|
}
|
|
|
|
func (a acpProjectAccessAdapter) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) {
|
|
return a.projectRepo.GetRequirementByNumber(ctx, projectID, number)
|
|
}
|
|
|
|
// acpArtifactAccessAdapter satisfies acp.ArtifactAccess. It loads all artifacts of a
|
|
// requirement and reduces to the latest version per phase (max version), so the
|
|
// brief composer receives at most one artifact per phase.
|
|
type acpArtifactAccessAdapter struct {
|
|
projectRepo project.Repository
|
|
}
|
|
|
|
func (a acpArtifactAccessAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) {
|
|
all, err := a.projectRepo.ListArtifactsByRequirement(ctx, requirementID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return latestArtifactsPerPhase(all), nil
|
|
}
|
|
|
|
// latestArtifactsPerPhase keeps the max-version artifact per phase. Deterministic:
|
|
// later versions strictly override earlier ones regardless of input order.
|
|
func latestArtifactsPerPhase(all []*project.Artifact) []*project.Artifact {
|
|
byPhase := make(map[project.Phase]*project.Artifact, len(all))
|
|
for _, art := range all {
|
|
if art == nil {
|
|
continue
|
|
}
|
|
cur, ok := byPhase[art.Phase]
|
|
if !ok || art.Version > cur.Version {
|
|
byPhase[art.Phase] = art
|
|
}
|
|
}
|
|
out := make([]*project.Artifact, 0, len(byPhase))
|
|
for _, art := range byPhase {
|
|
out = append(out, art)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// chatContextReaderAdapter satisfies chat.ContextReader. It resolves a conversation's
|
|
// FK mounts (requirement / issue / project) into domain objects + latest artifacts,
|
|
// then delegates to brief.Composer. Repo orientation is intentionally skipped for chat
|
|
// (RepoCwd left empty) to keep the request-path latency bounded — git shell-outs only
|
|
// run for ACP sessions.
|
|
type chatContextReaderAdapter struct {
|
|
projectRepo project.Repository
|
|
composer brief.Composer
|
|
}
|
|
|
|
func (a chatContextReaderAdapter) BriefForConversation(ctx context.Context, conv *chat.Conversation) (string, error) {
|
|
if conv == nil || a.composer == nil {
|
|
return "", nil
|
|
}
|
|
|
|
var (
|
|
req *project.Requirement
|
|
iss *project.Issue
|
|
pj *project.Project
|
|
artifacts []*project.Artifact
|
|
kind = brief.KindChatRequirement
|
|
)
|
|
|
|
if conv.RequirementID != nil {
|
|
if r, err := a.projectRepo.GetRequirementByID(ctx, *conv.RequirementID); err == nil {
|
|
req = r
|
|
if arts, aerr := a.projectRepo.ListArtifactsByRequirement(ctx, r.ID, ""); aerr == nil {
|
|
artifacts = latestArtifactsPerPhase(arts)
|
|
}
|
|
}
|
|
}
|
|
if conv.IssueID != nil {
|
|
if i, err := a.projectRepo.GetIssueByID(ctx, *conv.IssueID); err == nil {
|
|
iss = i
|
|
kind = brief.KindChatIssue
|
|
}
|
|
}
|
|
if conv.ProjectID != nil {
|
|
if p, err := a.projectRepo.GetProjectByID(ctx, *conv.ProjectID); err == nil {
|
|
pj = p
|
|
}
|
|
}
|
|
|
|
// No PM context resolved → no FK brief.
|
|
if req == nil && iss == nil {
|
|
return "", nil
|
|
}
|
|
|
|
res, err := a.composer.ComposeTaskBrief(ctx, brief.BriefInput{
|
|
Project: pj,
|
|
Requirement: req,
|
|
Issue: iss,
|
|
Artifacts: artifacts,
|
|
UserPrompt: "", // chat 的用户输入走消息历史,不进 FK 简报
|
|
Kind: kind,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return res.Text, nil
|
|
}
|
|
|
|
// ===== Adapters: bridge cross-module types into the narrow interfaces the
|
|
// jobs/runners package expects, so the runners package never imports
|
|
// workspace/chat/notify.
|
|
|
|
// workspaceFetchAdapter implements runners.WorkspaceFetchRepo backed by
|
|
// workspace.Repository. Translates workspace.FetchTarget into
|
|
// runners.WorkspaceFetchTarget (same shape).
|
|
type workspaceFetchAdapter struct{ repo workspace.Repository }
|
|
|
|
func (a workspaceFetchAdapter) ListFetchTargets(ctx context.Context) ([]runners.WorkspaceFetchTarget, error) {
|
|
targets, err := a.repo.ListFetchTargets(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]runners.WorkspaceFetchTarget, 0, len(targets))
|
|
for _, t := range targets {
|
|
out = append(out, runners.WorkspaceFetchTarget{
|
|
ID: t.ID, OwnerID: t.OwnerID, Name: t.Name, MainPath: t.MainPath,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a workspaceFetchAdapter) MarkSyncing(ctx context.Context, id uuid.UUID) (bool, error) {
|
|
return a.repo.MarkSyncing(ctx, id)
|
|
}
|
|
|
|
func (a workspaceFetchAdapter) MarkFetchResult(ctx context.Context, id uuid.UUID, success bool, errMsg string) error {
|
|
return a.repo.MarkFetchResult(ctx, id, success, errMsg)
|
|
}
|
|
|
|
// workspaceFetcherAdapter implements runners.WorkspaceFetcher by loading the
|
|
// workspace credential and calling git.Runner.Fetch directly.
|
|
type workspaceFetcherAdapter struct {
|
|
gitr git.Runner
|
|
credLoader func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error)
|
|
}
|
|
|
|
func (a workspaceFetcherAdapter) Fetch(ctx context.Context, mainPath string, wsID uuid.UUID) error {
|
|
cred, err := a.credLoader(ctx, wsID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// 后台 fetch 成功后与手动 Sync 一致地把 main 工作区快进到远端
|
|
if err := a.gitr.Fetch(ctx, mainPath, cred); err != nil {
|
|
return err
|
|
}
|
|
return a.gitr.MergeFFOnly(ctx, mainPath)
|
|
}
|
|
|
|
// projectLookup is the minimal interface needed by worktreePruneAdapter to
|
|
// look up a project by ID without ACL enforcement (background system job path).
|
|
type projectLookup interface {
|
|
GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error)
|
|
}
|
|
|
|
// worktreePruneAdapter implements runners.WorktreePruneRepo by composing the
|
|
// workspace.Repository's worktree-prune methods with per-worktree workspace+
|
|
// project lookups (to populate OwnerID and MainPath that aren't on the
|
|
// workspace_worktrees row). N+1 acceptable: prune lists are small per tick.
|
|
type worktreePruneAdapter struct {
|
|
wsRepo workspace.Repository
|
|
pLookup projectLookup
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (a worktreePruneAdapter) ListWorktreesNeedingPruneWarning(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) {
|
|
rows, err := a.wsRepo.ListWorktreesNeedingPruneWarning(ctx, lastUsedBefore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.enrichWorktrees(ctx, rows), nil
|
|
}
|
|
|
|
func (a worktreePruneAdapter) ListWorktreesNeedingPrune(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) {
|
|
rows, err := a.wsRepo.ListWorktreesNeedingPrune(ctx, lastUsedBefore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.enrichWorktrees(ctx, rows), nil
|
|
}
|
|
|
|
func (a worktreePruneAdapter) MarkPruneWarning(ctx context.Context, id uuid.UUID) error {
|
|
return a.wsRepo.MarkPruneWarning(ctx, id)
|
|
}
|
|
|
|
func (a worktreePruneAdapter) TryStartPrune(ctx context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
|
|
wt, err := a.wsRepo.TryStartPrune(ctx, id)
|
|
if err != nil {
|
|
return runners.WorktreeRow{}, err
|
|
}
|
|
return a.enrichOne(ctx, wt), nil
|
|
}
|
|
|
|
func (a worktreePruneAdapter) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error {
|
|
return a.wsRepo.FinishPruneSuccess(ctx, id)
|
|
}
|
|
|
|
func (a worktreePruneAdapter) FinishPruneRollback(ctx context.Context, id uuid.UUID) error {
|
|
return a.wsRepo.FinishPruneRollback(ctx, id)
|
|
}
|
|
|
|
// enrichWorktrees converts []*workspace.Worktree to []runners.WorktreeRow.
|
|
func (a worktreePruneAdapter) enrichWorktrees(ctx context.Context, rows []*workspace.Worktree) []runners.WorktreeRow {
|
|
out := make([]runners.WorktreeRow, 0, len(rows))
|
|
for _, w := range rows {
|
|
out = append(out, a.enrichOne(ctx, w))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a worktreePruneAdapter) enrichOne(ctx context.Context, w *workspace.Worktree) runners.WorktreeRow {
|
|
row := runners.WorktreeRow{ID: w.ID, WorkspaceID: w.WorkspaceID, Branch: w.Branch, Path: w.Path}
|
|
ws, err := a.wsRepo.GetWorkspaceByID(ctx, w.WorkspaceID)
|
|
if err != nil {
|
|
a.log.Warn("worktree_prune.adapter.lookup_workspace_failed",
|
|
"worktree_id", w.ID, "workspace_id", w.WorkspaceID, "err", err.Error())
|
|
return row
|
|
}
|
|
row.MainPath = ws.MainPath
|
|
pj, err := a.pLookup.GetProjectByID(ctx, ws.ProjectID)
|
|
if err != nil {
|
|
a.log.Warn("worktree_prune.adapter.lookup_project_failed",
|
|
"worktree_id", w.ID, "project_id", ws.ProjectID, "err", err.Error())
|
|
return row
|
|
}
|
|
row.OwnerID = pj.OwnerID
|
|
return row
|
|
}
|
|
|
|
// chatAttachmentAdapter implements runners.AttachmentCleanupRepo backed by
|
|
// chat.Repository. Translates chat.AttachmentRef → runners.AttachmentRow.
|
|
type chatAttachmentAdapter struct{ repo chat.Repository }
|
|
|
|
func (a chatAttachmentAdapter) ListAttachmentsForSoftDeletedConversations(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) {
|
|
refs, err := a.repo.ListAttachmentsForSoftDeletedConversations(ctx, olderThan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return refsToRows(refs), nil
|
|
}
|
|
|
|
func (a chatAttachmentAdapter) ListExpiredOrphans(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) {
|
|
refs, err := a.repo.ListExpiredOrphans(ctx, olderThan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return refsToRows(refs), nil
|
|
}
|
|
|
|
func (a chatAttachmentAdapter) DeleteAttachments(ctx context.Context, ids []uuid.UUID) error {
|
|
return a.repo.DeleteAttachments(ctx, ids)
|
|
}
|
|
|
|
func refsToRows(refs []chat.AttachmentRef) []runners.AttachmentRow {
|
|
out := make([]runners.AttachmentRow, 0, len(refs))
|
|
for _, r := range refs {
|
|
out = append(out, runners.AttachmentRow{ID: r.ID, StoragePath: r.StoragePath})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dispatcherAdapter implements runners.Dispatcher backed by *notify.Dispatcher.
|
|
type dispatcherAdapter struct{ d *notify.Dispatcher }
|
|
|
|
func (a dispatcherAdapter) Dispatch(ctx context.Context, msg notify.Message) error {
|
|
return a.d.Dispatch(ctx, msg)
|
|
}
|
|
|
|
// emailLookupAdapter implements notify.EmailLookup over user.Service so the
|
|
// EmailNotifier can resolve a recipient address from a userID without notify
|
|
// importing the user package.
|
|
type emailLookupAdapter struct{ svc user.Service }
|
|
|
|
func (a emailLookupAdapter) EmailForUser(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
u, err := a.svc.Get(ctx, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return u.Email, nil
|
|
}
|
|
|
|
// phaseEventEmitterAdapter implements project.PhaseEventEmitter by translating a
|
|
// PhaseChangeEvent into a notify.Message (Topic="requirement.phase_change") and
|
|
// dispatching it. Fire-and-forget: dispatch errors are logged inside Dispatch,
|
|
// never surfaced, so a notify outage never blocks a phase change.
|
|
type phaseEventEmitterAdapter struct{ d *notify.Dispatcher }
|
|
|
|
func (a phaseEventEmitterAdapter) EmitPhaseChange(ctx context.Context, e project.PhaseChangeEvent) {
|
|
meta := map[string]any{
|
|
"project_id": e.ProjectID.String(),
|
|
"project_slug": e.ProjectSlug,
|
|
"requirement_id": e.RequirementID.String(),
|
|
"requirement_number": e.RequirementNumber,
|
|
"from": string(e.From),
|
|
"to": string(e.To),
|
|
"actor_id": e.ActorID.String(),
|
|
}
|
|
// 主要接收人是需求 owner(关心进度的人),而非发起变更的 actor(可能是编排器
|
|
// 或其它协作者)。owner 与 actor 不同则两者都通知;相同则只发一条避免重复。
|
|
recipients := map[uuid.UUID]struct{}{}
|
|
if e.OwnerID != uuid.Nil {
|
|
recipients[e.OwnerID] = struct{}{}
|
|
}
|
|
if e.ActorID != uuid.Nil {
|
|
recipients[e.ActorID] = struct{}{}
|
|
}
|
|
for uid := range recipients {
|
|
_ = a.d.Dispatch(ctx, notify.Message{
|
|
UserID: uid,
|
|
Topic: "requirement.phase_change",
|
|
Severity: notify.SeverityInfo,
|
|
Title: "Requirement phase changed",
|
|
Metadata: meta,
|
|
})
|
|
}
|
|
}
|
|
|
|
// workspaceStateAdapter implements project.WorkspaceStateLookup over the
|
|
// change-request repository: the Done gate passes when THIS requirement has a
|
|
// change request in state merged (requirement-scoped, not "any PR in the
|
|
// workspace"). Backed by changerequest.Repository so the gate runs in a system
|
|
// context without per-caller auth.
|
|
type workspaceStateAdapter struct{ crRepo changerequest.Repository }
|
|
|
|
func (a workspaceStateAdapter) PullRequestMerged(ctx context.Context, workspaceID, requirementID uuid.UUID) (bool, error) {
|
|
crs, err := a.crRepo.ListByWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, cr := range crs {
|
|
if cr.State == changerequest.StateMerged && cr.RequirementID != nil && *cr.RequirementID == requirementID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// acpEventsPurgeAdapter exposes acp.Repository.PurgeEventsBefore as the narrow
|
|
// interface required by runners.AcpEventsPurge.
|
|
type acpEventsPurgeAdapter struct{ repo acp.Repository }
|
|
|
|
func (a acpEventsPurgeAdapter) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) {
|
|
return a.repo.PurgeEventsBefore(ctx, before)
|
|
}
|
|
|
|
// acpTurnsPurgeAdapter exposes acp.Repository.PurgeTurnsBefore as the narrow
|
|
// interface required by runners.AcpTurnsPurge.
|
|
type acpTurnsPurgeAdapter struct{ repo acp.Repository }
|
|
|
|
func (a acpTurnsPurgeAdapter) PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) {
|
|
return a.repo.PurgeTurnsBefore(ctx, before)
|
|
}
|
|
|
|
// mcpTokensPurgeAdapter exposes mcp.Repository.PurgeExpired as the narrow
|
|
// interface required by runners.MCPTokensPurge.
|
|
type mcpTokensPurgeAdapter struct{ repo mcp.Repository }
|
|
|
|
func (a mcpTokensPurgeAdapter) PurgeExpired(ctx context.Context, before time.Time) (int64, error) {
|
|
return a.repo.PurgeExpired(ctx, before)
|
|
}
|
|
|
|
// acpModelPriceAdapter resolves an LLM model_id to its prices for ACP cost
|
|
// accounting, bridging acp.ModelPriceLookup to chat.Repository.GetLLMModel.
|
|
// Reuses the single pricing source (chat) without an acp→chat compile-time dep.
|
|
type acpModelPriceAdapter struct{ repo chat.Repository }
|
|
|
|
func (a acpModelPriceAdapter) PriceForModel(ctx context.Context, modelID uuid.UUID) (acp.ModelPrice, error) {
|
|
m, err := a.repo.GetLLMModel(ctx, modelID)
|
|
if err != nil || m == nil {
|
|
return acp.ModelPrice{Found: false}, nil
|
|
}
|
|
return acp.ModelPrice{
|
|
PromptPerM: m.PromptPricePerMillionUSD,
|
|
CompletionPerM: m.CompletionPricePerMillionUSD,
|
|
ThinkingPerM: m.ThinkingPricePerMillionUSD,
|
|
Found: true,
|
|
}, nil
|
|
}
|
|
|
|
// acpModelValidatorAdapter validates that a model_id references an enabled
|
|
// llm_model when an admin assigns one to an agent kind.
|
|
type acpModelValidatorAdapter struct{ repo chat.Repository }
|
|
|
|
func (a acpModelValidatorAdapter) IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error) {
|
|
m, err := a.repo.GetLLMModel(ctx, modelID)
|
|
if err != nil || m == nil {
|
|
return false, nil
|
|
}
|
|
return m.Enabled, nil
|
|
}
|
|
|
|
// acpSessionReaperAdapter exposes the ACP repo's reaper surface as the narrow
|
|
// interface required by runners.AcpSessionReaper.
|
|
type acpSessionReaperAdapter struct{ repo acp.Repository }
|
|
|
|
func (a acpSessionReaperAdapter) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]runners.ReaperSession, error) {
|
|
rows, err := a.repo.ListSessionsForReaper(ctx, wallClockStartedBefore, idleSince)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]runners.ReaperSession, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, runners.ReaperSession{
|
|
ID: r.ID,
|
|
UserID: r.UserID,
|
|
StartedAt: r.StartedAt,
|
|
LastActivityAt: r.LastActivityAt,
|
|
MaxWallClockSeconds: r.MaxWallClockSeconds,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a acpSessionReaperAdapter) MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error {
|
|
return a.repo.MarkSessionTerminatedReason(ctx, id, reason)
|
|
}
|
|
|
|
// ===== code index / project memory / docs adapters =====
|
|
|
|
// embeddingSelector resolves the configured platform embedder from cfg.CodeIndex.
|
|
// When no embedding endpoint is configured it returns codeindex.ErrEmbeddingDisabled
|
|
// so callers degrade to keyword fallback (memory) or a typed disabled error (search).
|
|
type embeddingSelector struct {
|
|
registry *llm.Registry
|
|
cfg config.CodeIndexConfig
|
|
}
|
|
|
|
func (s *embeddingSelector) Embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) {
|
|
if s.cfg.EmbeddingEndpointID == "" {
|
|
return nil, uuid.Nil, "", 0, codeindex.ErrEmbeddingDisabled
|
|
}
|
|
endpointID, err := uuid.Parse(s.cfg.EmbeddingEndpointID)
|
|
if err != nil {
|
|
return nil, uuid.Nil, "", 0, fmt.Errorf("code_index.embedding_endpoint_id parse: %w", err)
|
|
}
|
|
emb, err := s.registry.GetEmbedder(ctx, endpointID)
|
|
if err != nil {
|
|
return nil, uuid.Nil, "", 0, err
|
|
}
|
|
return emb, endpointID, s.cfg.EmbeddingModel, llm.PlatformEmbeddingDims, nil
|
|
}
|
|
|
|
// workspaceLocatorAdapter resolves workspace/worktree on-disk paths for codeindex
|
|
// without exposing the full workspace.Repository (and without an import cycle:
|
|
// codeindex defines the narrow interface, app implements it over wsRepo).
|
|
type workspaceLocatorAdapter struct{ repo workspace.Repository }
|
|
|
|
func (a *workspaceLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (codeindex.WorkspaceInfo, error) {
|
|
w, err := a.repo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return codeindex.WorkspaceInfo{}, err
|
|
}
|
|
return codeindex.WorkspaceInfo{ID: w.ID, MainPath: w.MainPath, DefaultBranch: w.DefaultBranch}, nil
|
|
}
|
|
|
|
func (a *workspaceLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, string, uuid.UUID, error) {
|
|
wt, err := a.repo.GetWorktreeByID(ctx, worktreeID)
|
|
if err != nil {
|
|
return "", "", uuid.Nil, err
|
|
}
|
|
return wt.Path, wt.Branch, wt.WorkspaceID, nil
|
|
}
|
|
|
|
// docsLocatorAdapter resolves on-disk directories for docs (commit summaries).
|
|
type docsLocatorAdapter struct{ repo workspace.Repository }
|
|
|
|
func (a *docsLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (string, error) {
|
|
w, err := a.repo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return w.MainPath, nil
|
|
}
|
|
|
|
func (a *docsLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, error) {
|
|
wt, err := a.repo.GetWorktreeByID(ctx, worktreeID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return wt.Path, nil
|
|
}
|
|
|
|
// commitSummarizer implements docs.Summarizer using the chat title-generator model
|
|
// via the llm registry. It collects text deltas from a single Stream into markdown.
|
|
type commitSummarizer struct {
|
|
registry *llm.Registry
|
|
chatRepo chat.Repository
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (s *commitSummarizer) Summarize(ctx context.Context, branch, commitSHA, diff string) (string, string, string, error) {
|
|
model, err := s.chatRepo.GetTitleGeneratorModel(ctx)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("resolve summary model: %w", err)
|
|
}
|
|
if model == nil {
|
|
return "", "", "", fmt.Errorf("no title-generator model configured for commit summaries")
|
|
}
|
|
client, err := s.registry.GetClient(ctx, model.EndpointID)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("resolve summary client: %w", err)
|
|
}
|
|
const sys = "You are a senior engineer writing concise, accurate commit summaries. " +
|
|
"Given a git commit's metadata and --stat, produce a short markdown summary: a one-line title, " +
|
|
"then a brief bullet list of what changed and why. Do not invent changes not implied by the stat."
|
|
prompt := fmt.Sprintf("Branch: %s\nCommit: %s\n\n```\n%s\n```", branch, commitSHA, diff)
|
|
stream, err := client.Stream(ctx, model.ModelID, llm.Request{
|
|
SystemPrompt: sys,
|
|
Messages: []llm.Message{{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: prompt}}}},
|
|
MaxOutputTokens: 800,
|
|
})
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("summary stream: %w", err)
|
|
}
|
|
var b strings.Builder
|
|
for ev := range stream {
|
|
switch ev.Type {
|
|
case llm.EventTextDelta:
|
|
b.WriteString(ev.Text)
|
|
case llm.EventError:
|
|
if ev.Err != nil {
|
|
return "", "", "", fmt.Errorf("summary stream: %w", ev.Err)
|
|
}
|
|
}
|
|
}
|
|
body := strings.TrimSpace(b.String())
|
|
if body == "" {
|
|
return "", "", "", fmt.Errorf("summary stream produced no text")
|
|
}
|
|
// Derive a title from the first markdown line.
|
|
title := firstLine(body)
|
|
return title, body, model.ModelID, nil
|
|
}
|
|
|
|
// firstLine returns the first non-empty line, stripped of leading markdown
|
|
// heading / bullet markers, capped to a reasonable title length.
|
|
func firstLine(s string) string {
|
|
for _, ln := range strings.Split(s, "\n") {
|
|
ln = strings.TrimSpace(ln)
|
|
ln = strings.TrimLeft(ln, "#-*> ")
|
|
if ln != "" {
|
|
if len(ln) > 200 {
|
|
ln = ln[:200]
|
|
}
|
|
return ln
|
|
}
|
|
}
|
|
return ""
|
|
}
|