diff --git a/internal/acp/agentkind_service.go b/internal/acp/agentkind_service.go new file mode 100644 index 0000000..0bd73c1 --- /dev/null +++ b/internal/acp/agentkind_service.go @@ -0,0 +1,182 @@ +package acp + +import ( + "context" + "encoding/json" + + "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" +) + +type agentKindService struct { + repo Repository + enc *crypto.Encryptor + audit audit.Recorder +} + +// NewAgentKindService 构造 AgentKindService。 +func NewAgentKindService(repo Repository, enc *crypto.Encryptor, rec audit.Recorder) AgentKindService { + return &agentKindService{repo: repo, enc: enc, audit: rec} +} + +func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) { + if !c.IsAdmin { + return nil, errs.New(errs.CodeForbidden, "admin only") + } + if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" { + return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required") + } + encrypted, err := encryptEnv(s.enc, in.Env) + if err != nil { + return nil, err + } + args := in.Args + if args == nil { + args = []string{} + } + k := &AgentKind{ + ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description, + BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled, + CreatedBy: c.UserID, + } + out, err := s.repo.CreateAgentKind(ctx, k) + if err != nil { + if IsUniqueViolation(err) { + return nil, errs.New(errs.CodeConflict, "agent kind name already exists") + } + return nil, err + } + s.recordAudit(ctx, c, "acp.agent_kind.create", out.ID.String(), + map[string]any{"name": out.Name, "binary_path": out.BinaryPath}) + return out, nil +} + +func (s *agentKindService) List(ctx context.Context, c Caller) ([]*AgentKind, error) { + if c.IsAdmin { + return s.repo.ListAgentKinds(ctx) + } + return s.repo.ListEnabledAgentKinds(ctx) +} + +func (s *agentKindService) Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error) { + k, err := s.repo.GetAgentKindByID(ctx, id) + if err != nil { + return nil, err + } + if !c.IsAdmin && !k.Enabled { + return nil, errs.New(errs.CodeAcpAgentKindNotFound, "agent kind not found") + } + return k, nil +} + +func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error) { + if !c.IsAdmin { + return nil, errs.New(errs.CodeForbidden, "admin only") + } + cur, err := s.repo.GetAgentKindByID(ctx, id) + if err != nil { + return nil, err + } + changed := map[string]any{} + if in.DisplayName != nil && *in.DisplayName != cur.DisplayName { + cur.DisplayName = *in.DisplayName + changed["display_name"] = "" + } + if in.Description != nil && *in.Description != cur.Description { + cur.Description = *in.Description + changed["description"] = "" + } + if in.BinaryPath != nil && *in.BinaryPath != cur.BinaryPath { + cur.BinaryPath = *in.BinaryPath + changed["binary_path"] = "" + } + if in.Args != nil { + cur.Args = in.Args + changed["args"] = "" + } + if in.Env != nil { + encrypted, eerr := encryptEnv(s.enc, in.Env) + if eerr != nil { + return nil, eerr + } + cur.EncryptedEnv = encrypted + changed["env"] = "" + } + if in.Enabled != nil && *in.Enabled != cur.Enabled { + cur.Enabled = *in.Enabled + changed["enabled"] = *in.Enabled + } + if len(changed) == 0 { + return cur, nil + } + out, err := s.repo.UpdateAgentKind(ctx, cur) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "acp.agent_kind.update", out.ID.String(), + map[string]any{"changed_fields": changed}) + return out, nil +} + +func (s *agentKindService) Delete(ctx context.Context, c Caller, id uuid.UUID) error { + if !c.IsAdmin { + return errs.New(errs.CodeForbidden, "admin only") + } + cur, err := s.repo.GetAgentKindByID(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteAgentKind(ctx, id); err != nil { + return err + } + s.recordAudit(ctx, c, "acp.agent_kind.delete", id.String(), + map[string]any{"name": cur.Name}) + return nil +} + +func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { + if s.audit == nil { + return + } + uid := c.UserID + _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ + UserID: &uid, Action: action, TargetType: "acp_agent_kind", TargetID: targetID, Metadata: meta, + }) +} + +// encryptEnv 把 map[string]string 序列化后加密。nil → 返回 nil(清空)。 +func encryptEnv(enc *crypto.Encryptor, env map[string]string) ([]byte, error) { + if env == nil { + return nil, nil + } + if len(env) == 0 { + return enc.Encrypt([]byte(`{}`)) + } + b, err := json.Marshal(env) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "marshal env") + } + return enc.Encrypt(b) +} + +// decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。 +func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) { + if len(encrypted) == 0 { + return map[string]string{}, nil + } + plain, err := 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 +} diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go new file mode 100644 index 0000000..1bfac4b --- /dev/null +++ b/internal/acp/agentkind_service_test.go @@ -0,0 +1,220 @@ +package acp_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "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" +) + +// fakeAgentKindRepo 是仅覆盖 AgentKind 方法的 fake;其他方法 panic +// (隔离单测,不引 testcontainers)。 +type fakeAgentKindRepo struct { + store map[uuid.UUID]*acp.AgentKind +} + +func newFakeAgentKindRepo() *fakeAgentKindRepo { + return &fakeAgentKindRepo{store: map[uuid.UUID]*acp.AgentKind{}} +} + +func (f *fakeAgentKindRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) { + cp := *k + f.store[k.ID] = &cp + return &cp, nil +} +func (f *fakeAgentKindRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) { + if k, ok := f.store[id]; ok { + cp := *k + return &cp, nil + } + return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") +} +func (f *fakeAgentKindRepo) GetAgentKindByName(_ context.Context, name string) (*acp.AgentKind, error) { + for _, k := range f.store { + if k.Name == name { + cp := *k + return &cp, nil + } + } + return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") +} +func (f *fakeAgentKindRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { + out := make([]*acp.AgentKind, 0, len(f.store)) + for _, k := range f.store { + cp := *k + out = append(out, &cp) + } + return out, nil +} +func (f *fakeAgentKindRepo) ListEnabledAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { + out := make([]*acp.AgentKind, 0) + for _, k := range f.store { + if k.Enabled { + cp := *k + out = append(out, &cp) + } + } + return out, nil +} +func (f *fakeAgentKindRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) { + if _, ok := f.store[k.ID]; !ok { + return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") + } + cp := *k + f.store[k.ID] = &cp + return &cp, nil +} +func (f *fakeAgentKindRepo) DeleteAgentKind(_ context.Context, id uuid.UUID) error { + delete(f.store, id) + return nil +} +func (f *fakeAgentKindRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { + return 0, nil +} + +// 其他方法 panic(不该被 AgentKindService 调用) +func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID) ([]*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListAllSessions(context.Context) ([]*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) CountActiveSessions(context.Context) (int64, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) CountActiveSessionsByUser(context.Context, uuid.UUID) (int64, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ResetStuckSessionsOnRestart(context.Context) ([]*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) AcquireMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ReleaseMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) InsertEvent(context.Context, *acp.Event) (*acp.Event, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32) ([]*acp.Event, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { + panic("n/a") +} + +// agentKindRecordingRecorder:accumulate audit entries +type agentKindRecordingRecorder struct{ entries []audit.Entry } + +func (r *agentKindRecordingRecorder) Record(_ context.Context, e audit.Entry) error { + r.entries = append(r.entries, e) + return nil +} + +// testEncryptor returns a real *crypto.Encryptor with a fixed test key. +func testEncryptor(t *testing.T) *crypto.Encryptor { + t.Helper() + key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes + enc, err := crypto.NewEncryptor(key) + require.NoError(t, err) + return enc +} + +func TestAgentKindService_Create(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo := newFakeAgentKindRepo() + rec := &agentKindRecordingRecorder{} + svc := acp.NewAgentKindService(repo, testEncryptor(t), rec) + + admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} + k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{ + Name: "claude_code", DisplayName: "Claude Code", + BinaryPath: "claude-code", Args: []string{"--acp"}, + Env: map[string]string{"K": "v"}, Enabled: true, + }) + require.NoError(t, err) + assert.Equal(t, "claude_code", k.Name) + assert.NotEmpty(t, k.EncryptedEnv) // env 已加密 + + require.Len(t, rec.entries, 1) + assert.Equal(t, "acp.agent_kind.create", rec.entries[0].Action) +} + +func TestAgentKindService_Create_NonAdmin(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), &agentKindRecordingRecorder{}) + + user := acp.Caller{UserID: uuid.New(), IsAdmin: false} + _, err := svc.Create(ctx, user, acp.CreateAgentKindInput{ + Name: "x", DisplayName: "x", BinaryPath: "x", Enabled: true, + }) + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestAgentKindService_List_NonAdmin_OnlyEnabled(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo := newFakeAgentKindRepo() + svc := acp.NewAgentKindService(repo, testEncryptor(t), nil) + admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} + user := acp.Caller{UserID: uuid.New(), IsAdmin: false} + + _, _ = svc.Create(ctx, admin, acp.CreateAgentKindInput{ + Name: "k1", DisplayName: "k1", BinaryPath: "x", Enabled: true, + }) + _, _ = svc.Create(ctx, admin, acp.CreateAgentKindInput{ + Name: "k2", DisplayName: "k2", BinaryPath: "x", Enabled: false, + }) + + adminList, _ := svc.List(ctx, admin) + userList, _ := svc.List(ctx, user) + assert.Len(t, adminList, 2) + assert.Len(t, userList, 1) +} + +func TestAgentKindService_Update_PatchSemantics(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo := newFakeAgentKindRepo() + rec := &agentKindRecordingRecorder{} + svc := acp.NewAgentKindService(repo, testEncryptor(t), rec) + admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} + + k, _ := svc.Create(ctx, admin, acp.CreateAgentKindInput{ + Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true, + }) + + disp := "Renamed" + updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{DisplayName: &disp}) + require.NoError(t, err) + assert.Equal(t, "Renamed", updated.DisplayName) + assert.Equal(t, "/x", updated.BinaryPath) // 未改 + + last := rec.entries[len(rec.entries)-1] + assert.Equal(t, "acp.agent_kind.update", last.Action) +}