Files
agentic-coding-workflow/internal/run/run_profile_service.go
T
2026-06-09 22:43:29 +08:00

426 lines
13 KiB
Go

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,
})
}