// 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 ""
}
