// 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" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/chat" "github.com/yan1h/agent-coding-workflow/internal/config" "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/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/storage" "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" "github.com/yan1h/agent-coding-workflow/internal/project" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "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 } // 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) userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, auditRec) requirementSvc := project.NewRequirementService(projectRepo, auditRec) issueSvc := project.NewIssueService(projectRepo, auditRec) notifyRepo := notify.NewPostgresRepository(pool) notifyDispatcher := notify.NewDispatcher(notifyRepo, log) notifyDispatcher.Register(notify.NewDummyNotifier(log)) 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, }) // chi.Router 接口本身已经声明 Use,无需类型断言到 *chi.Mux。 r.Use(middleware.RequestID) r.Use(middleware.Recover(log)) r.Use(middleware.Logger(log)) // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。 user.NewHandler(userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, 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) } enc, err := crypto.NewEncryptor(cfg.MasterKey) if err != nil { return nil, fmt.Errorf("crypto encryptor: %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, }) 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) // ===== 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, }) 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) // ===== 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()) } } 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 2: build supervisor + session service. acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo} acpSup := acp.NewSupervisor( acpRepo, auditRec, notifyDispatcher, wtSvc, wsRepo, 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, }, log, ) sessSvc := acp.NewSessionService( acpRepo, wsSvc, wtSvc, wsRepo, acpProjAccess, acpSup, auditRec, acp.SessionServiceConfig{ MaxActiveGlobal: cfg.Acp.MaxActiveGlobal, MaxActivePerUser: cfg.Acp.MaxActivePerUser, }, ) acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, PongTimeout: cfg.Acp.WSPongTimeout, SendBuffer: cfg.Acp.WSSendBuffer, }).Mount(r) // ===== jobs module wiring ===== jobsRepo := jobs.NewPostgresRepository(pool) 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) deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) { 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, }, }, jobs.ModuleDeps{ Repo: jobsRepo, Handlers: map[jobs.JobType]jobs.Handler{ jobs.TypeWebhookDeliver: webhookHandler, }, 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, }, 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) } } 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, }, 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() { if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() 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 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.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 复用。 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 } // 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 } canRead, canWrite := projectACL(pj, callerID, isAdmin) 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 } canRead, canWrite := projectACL(pj, callerID, isAdmin) return canRead, canWrite, nil } // projectACL 复制 project 包内部 assertReadable/assertWritable 的语义: // - admin 永远可读可写 // - owner 可读可写 // - internal 可见性:所有登录用户可读 // // 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess // 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。 func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) { owned := pj.OwnerID == callerID canWrite = isAdmin || owned canRead = canWrite || pj.Visibility == project.VisibilityInternal return canRead, canWrite } // 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) } // ===== 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 } return a.gitr.Fetch(ctx, mainPath, cred) } // 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) }