package run import ( "context" "encoding/json" "errors" "log/slog" "os" "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // ServiceConfig 是 Service 的运行参数子集(派生自 config.RunConfig)。 type ServiceConfig struct { EnvWhitelist []string KillGrace time.Duration OutTailBytes int } // Service 编排 run profile 的 CRUD 与进程生命周期(start/stop/restart/status/logs)。 // 鉴权完全继承所属 workspace:读靠 visibility,写靠 owner+admin(经 ProjectAccess)。 type Service struct { repo Repository wsRepo workspace.Repository sup *RunSupervisor pa workspace.ProjectAccess rec audit.Recorder enc *crypto.Encryptor cfg ServiceConfig log *slog.Logger } // NewService 构造 run.Service。注入 workspace.Repository 以解析 main_path/project_id, // ProjectAccess 做 owner/admin 写鉴权(复用 workspace 的窄接口)。 func NewService( repo Repository, wsRepo workspace.Repository, sup *RunSupervisor, pa workspace.ProjectAccess, rec audit.Recorder, enc *crypto.Encryptor, cfg ServiceConfig, log *slog.Logger, ) *Service { if log == nil { log = slog.Default() } return &Service{repo: repo, wsRepo: wsRepo, sup: sup, pa: pa, rec: rec, enc: enc, cfg: cfg, log: log} } // ===== 鉴权 helper ===== // authByWorkspace 解析 workspace 并校验调用方读/写权限。write=true 要求 owner+admin。 func (s *Service) authByWorkspace(ctx context.Context, c workspace.Caller, wsID uuid.UUID, write bool) (*workspace.Workspace, error) { ws, err := s.wsRepo.GetWorkspaceByID(ctx, wsID) if err != nil { return nil, err } canRead, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID) if err != nil { return nil, err } if write { if !canWrite { return nil, errs.New(errs.CodeForbidden, "no write access to this workspace") } } else if !canRead { return nil, errs.New(errs.CodeForbidden, "no read access to this workspace") } return ws, nil } // authByProfile 取 profile → 其 workspace,并校验权限。返回 profile 与 workspace。 func (s *Service) authByProfile(ctx context.Context, c workspace.Caller, profileID uuid.UUID, write bool) (*RunProfile, *workspace.Workspace, error) { p, err := s.repo.GetRunProfileByID(ctx, profileID) if err != nil { return nil, nil, err } ws, err := s.authByWorkspace(ctx, c, p.WorkspaceID, write) if err != nil { return nil, nil, err } return p, ws, nil } // ===== CRUD ===== func (s *Service) List(ctx context.Context, c workspace.Caller, wsID uuid.UUID) ([]RunProfileResp, error) { if _, err := s.authByWorkspace(ctx, c, wsID, false); err != nil { return nil, err } profiles, err := s.repo.ListRunProfilesByWorkspace(ctx, wsID) if err != nil { return nil, err } out := make([]RunProfileResp, 0, len(profiles)) for _, p := range profiles { out = append(out, s.toResp(p)) } return out, nil } func (s *Service) Create(ctx context.Context, c workspace.Caller, wsID uuid.UUID, req CreateRunProfileReq) (RunProfileResp, error) { if _, err := s.authByWorkspace(ctx, c, wsID, true); err != nil { return RunProfileResp{}, err } if strings.TrimSpace(req.Command) == "" { return RunProfileResp{}, errs.New(errs.CodeRunCommandInvalid, "command must not be empty") } encEnv, err := s.encryptEnv(req.Env) if err != nil { return RunProfileResp{}, err } enabled := true if req.Enabled != nil { enabled = *req.Enabled } p := &RunProfile{ ID: uuid.New(), WorkspaceID: wsID, Slug: req.Slug, Name: req.Name, Description: req.Description, Command: req.Command, Args: req.Args, EncryptedEnv: encEnv, Enabled: enabled, CreatedBy: c.UserID, } created, err := s.repo.CreateRunProfile(ctx, p) if err != nil { return RunProfileResp{}, err } s.audit(ctx, c, "run.profile.created", created.ID, map[string]any{"workspace_id": wsID.String(), "slug": created.Slug}) return s.toResp(created), nil } func (s *Service) Update(ctx context.Context, c workspace.Caller, profileID uuid.UUID, req UpdateRunProfileReq) (RunProfileResp, error) { p, _, err := s.authByProfile(ctx, c, profileID, true) if err != nil { return RunProfileResp{}, err } if req.Slug != nil { p.Slug = *req.Slug } if req.Name != nil { p.Name = *req.Name } if req.Description != nil { p.Description = *req.Description } if req.Command != nil { if strings.TrimSpace(*req.Command) == "" { return RunProfileResp{}, errs.New(errs.CodeRunCommandInvalid, "command must not be empty") } p.Command = *req.Command } if req.Args != nil { p.Args = *req.Args } if req.Enabled != nil { p.Enabled = *req.Enabled } if req.Env != nil { encEnv, encErr := s.encryptEnv(*req.Env) if encErr != nil { return RunProfileResp{}, encErr } p.EncryptedEnv = encEnv } updated, err := s.repo.UpdateRunProfile(ctx, p) if err != nil { return RunProfileResp{}, err } s.audit(ctx, c, "run.profile.updated", updated.ID, map[string]any{"workspace_id": updated.WorkspaceID.String()}) return s.toResp(updated), nil } func (s *Service) Delete(ctx context.Context, c workspace.Caller, profileID uuid.UUID) error { p, _, err := s.authByProfile(ctx, c, profileID, true) if err != nil { return err } // 运行中先整树停掉,避免删行后留下无主进程。 if _, running := s.sup.Get(profileID); running { _ = s.sup.Stop(ctx, profileID, s.cfg.KillGrace) } if err := s.repo.DeleteRunProfile(ctx, profileID); err != nil { return err } s.audit(ctx, c, "run.profile.deleted", profileID, map[string]any{"workspace_id": p.WorkspaceID.String()}) return nil } // ===== 进程生命周期 ===== func (s *Service) Start(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) { p, ws, err := s.authByProfile(ctx, c, profileID, true) if err != nil { return RunStatusResp{}, err } if !p.Enabled { return RunStatusResp{}, errs.New(errs.CodeRunProfileDisabled, "run profile is disabled") } overrides, err := s.decryptEnv(p.EncryptedEnv) if err != nil { return RunStatusResp{}, err } _, err = s.sup.Spawn(SpawnParams{ ProfileID: p.ID, Command: p.Command, Args: p.Args, Env: s.buildEnv(overrides), Dir: ws.MainPath, }) if err != nil { if errors.Is(err, ErrAlreadyRunning) { return RunStatusResp{}, errs.New(errs.CodeRunProfileAlreadyRunning, "run profile already running") } return RunStatusResp{}, errs.Wrap(err, errs.CodeRunSpawnFailed, "spawn run profile") } if _, err := s.repo.MarkRunProfileStarted(ctx, p.ID); err != nil { s.log.Error("run.start.mark_started", "profile_id", p.ID, "err", err.Error()) } s.audit(ctx, c, "run.profile.started", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()}) return s.statusResp(p.ID), nil } func (s *Service) Stop(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) { p, _, err := s.authByProfile(ctx, c, profileID, true) if err != nil { return RunStatusResp{}, err } if err := s.sup.Stop(ctx, p.ID, s.cfg.KillGrace); err != nil { if errors.Is(err, ErrNotRunning) { return RunStatusResp{}, errs.New(errs.CodeRunProfileNotRunning, "run profile is not running") } return RunStatusResp{}, errs.Wrap(err, errs.CodeInternal, "stop run profile") } s.audit(ctx, c, "run.profile.stopped", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()}) return s.statusResp(p.ID), nil } func (s *Service) Restart(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) { p, ws, err := s.authByProfile(ctx, c, profileID, true) if err != nil { return RunStatusResp{}, err } if !p.Enabled { return RunStatusResp{}, errs.New(errs.CodeRunProfileDisabled, "run profile is disabled") } if _, running := s.sup.Get(p.ID); running { _ = s.sup.Stop(ctx, p.ID, s.cfg.KillGrace) } overrides, err := s.decryptEnv(p.EncryptedEnv) if err != nil { return RunStatusResp{}, err } if _, err := s.sup.Spawn(SpawnParams{ ProfileID: p.ID, Command: p.Command, Args: p.Args, Env: s.buildEnv(overrides), Dir: ws.MainPath, }); err != nil { if errors.Is(err, ErrAlreadyRunning) { return RunStatusResp{}, errs.New(errs.CodeRunProfileAlreadyRunning, "run profile already running") } return RunStatusResp{}, errs.Wrap(err, errs.CodeRunSpawnFailed, "spawn run profile") } if _, err := s.repo.MarkRunProfileStarted(ctx, p.ID); err != nil { s.log.Error("run.restart.mark_started", "profile_id", p.ID, "err", err.Error()) } s.audit(ctx, c, "run.profile.restarted", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()}) return s.statusResp(p.ID), nil } func (s *Service) Status(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) { p, _, err := s.authByProfile(ctx, c, profileID, false) if err != nil { return RunStatusResp{}, err } return s.statusRespFrom(p), nil } // Logs 返回正在运行进程的内存日志(ID > since,最多 limit 条)。 func (s *Service) Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]LogLineResp, error) { if _, _, err := s.authByProfile(ctx, c, profileID, false); err != nil { return nil, err } envs := s.sup.LogsSince(profileID, since, limit) out := make([]LogLineResp, 0, len(envs)) for _, e := range envs { out = append(out, toLogLineResp(e)) } return out, nil } // SubscribeLogs 暴露给 WS handler:为运行中 profile 注册订阅,返回订阅句柄与 relay。 // 同时返回鉴权后的 profile(未运行时 relay 为 nil)。 func (s *Service) SubscribeLogs(ctx context.Context, c workspace.Caller, profileID, userID uuid.UUID) (*Subscriber, *runRelay, bool, error) { if _, _, err := s.authByProfile(ctx, c, profileID, false); err != nil { return nil, nil, false, err } sub, relay, ok := s.sup.Subscribe(profileID, userID) return sub, relay, ok, nil } // ===== 内部 helper ===== // buildEnv 构造被托管命令的环境:宿主白名单基础变量 + profile 覆盖。 // 不继承宿主全部环境,避免泄漏 APP_MASTER_KEY / DB DSN / git 凭据等敏感变量。 func (s *Service) buildEnv(overrides map[string]string) []string { base := map[string]string{} for _, k := range s.cfg.EnvWhitelist { if v, ok := os.LookupEnv(k); ok { base[k] = v } } for k, v := range overrides { base[k] = v } out := make([]string, 0, len(base)) for k, v := range base { out = append(out, k+"="+v) } return out } func (s *Service) encryptEnv(env map[string]string) ([]byte, error) { if len(env) == 0 { return nil, nil } b, err := json.Marshal(env) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "marshal env") } ct, err := s.enc.Encrypt(b) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "encrypt env") } return ct, nil } func (s *Service) decryptEnv(encrypted []byte) (map[string]string, error) { if len(encrypted) == 0 { return map[string]string{}, nil } plain, err := s.enc.Decrypt(encrypted) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "decrypt env") } var m map[string]string if err := json.Unmarshal(plain, &m); err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal env") } if m == nil { m = map[string]string{} } return m, nil } // envKeys 解密并返回排序后的 env key 列表(仅 key,供前端展示)。解密失败回空列表。 func (s *Service) envKeys(encrypted []byte) []string { m, err := s.decryptEnv(encrypted) if err != nil { s.log.Warn("run.env_keys.decrypt_failed", "err", err.Error()) return []string{} } return sortedKeys(m) } func (s *Service) toResp(p *RunProfile) RunProfileResp { status := s.sup.StatusOf(p.ID) var startedAt *time.Time if proc, ok := s.sup.Get(p.ID); ok { t := proc.StartedAt startedAt = &t } return buildRunProfileResp(p, s.envKeys(p.EncryptedEnv), status, startedAt) } func (s *Service) statusResp(profileID uuid.UUID) RunStatusResp { p, err := s.repo.GetRunProfileByID(context.Background(), profileID) if err != nil { return RunStatusResp{Status: string(s.sup.StatusOf(profileID))} } return s.statusRespFrom(p) } func (s *Service) statusRespFrom(p *RunProfile) RunStatusResp { status := s.sup.StatusOf(p.ID) var startedAt *string if proc, ok := s.sup.Get(p.ID); ok { t := proc.StartedAt.UTC().Format(time.RFC3339) startedAt = &t } return RunStatusResp{ Status: string(status), StartedAt: startedAt, LastExitCode: p.LastExitCode, LastError: p.LastError, } } func (s *Service) audit(ctx context.Context, c workspace.Caller, action string, targetID uuid.UUID, meta map[string]any) { if s.rec == nil { return } uid := c.UserID _ = s.rec.Record(ctx, audit.Entry{ UserID: &uid, Action: action, TargetType: "run_profile", TargetID: targetID.String(), Metadata: meta, }) }