From d2ef10763c085f0daac50a5270080ff424a559cd Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 20:07:51 +0800 Subject: [PATCH] chore: align forest AI development setup --- .gitignore | 7 +++ backend/etc/agent.dev.yaml | 3 +- backend/internal/config/config.go | 14 +++++ backend/internal/config/config_test.go | 53 +++++++++++++++++++ backend/internal/seed/demo.go | 3 +- backend/internal/seed/demo_test.go | 72 +++++++++++++++++++++++++- infra/docker-compose.yml | 1 + 7 files changed, 150 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 69b9bdf..6515bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,11 @@ coverage/ *.log test-results/ playwright-report/ +.superpowers/ +backend/tmp/ +backend/data/ +apps/web_v1/dist/ +apps/web_v1/test-results/ +apps/web_v1/playwright-report/ +apps/desktop/src-tauri/target/ .worktrees/ diff --git a/backend/etc/agent.dev.yaml b/backend/etc/agent.dev.yaml index bb89521..0707534 100644 --- a/backend/etc/agent.dev.yaml +++ b/backend/etc/agent.dev.yaml @@ -1,6 +1,7 @@ +# 仅供本地开发使用;以下默认凭据与密钥不得用于生产环境。 env: development port: "9150" -dsn: "postgres://postgres:postgres@localhost:5432/agent_dev?sslmode=disable" +dsn: "postgres://agent:agent@localhost:5432/agent?sslmode=disable" storage_dir: "./data/files" max_upload_bytes: 33554432 auth_secret: "development-auth-secret-change-me" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 51bcaaf..c1f1585 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -45,5 +46,18 @@ func LoadFromDir(configDir string) (Config, error) { if cfg.MaxUploadBytes <= 0 { cfg.MaxUploadBytes = 32 << 20 } + if strings.TrimSpace(cfg.StorageDir) == "" { + return Config{}, fmt.Errorf("storage_dir must not be empty") + } + hasAllowedOrigin := false + for _, origin := range cfg.AllowedOrigins { + if strings.TrimSpace(origin) != "" { + hasAllowedOrigin = true + break + } + } + if !hasAllowedOrigin { + return Config{}, fmt.Errorf("allowed_origins must include at least one origin") + } return cfg, nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index ad90a9c..75d0f0d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -3,11 +3,38 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" ) +func TestDevelopmentConfigMatchesLocalWorkspaceContract(t *testing.T) { + t.Setenv("SENLIN_APP_MODE", "dev") + + cfg, err := LoadFromDir(filepath.Join("..", "..", "etc")) + + require.NoError(t, err) + require.Equal(t, "development", cfg.Env) + require.Equal(t, "9150", cfg.Port) + require.Equal(t, "postgres://agent:agent@localhost:5432/agent?sslmode=disable", cfg.DSN) + require.NotEmpty(t, strings.TrimSpace(cfg.StorageDir)) + require.Equal(t, int64(32<<20), cfg.MaxUploadBytes) + require.ElementsMatch(t, []string{ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173", + "http://localhost:4174", + "http://127.0.0.1:4174", + "http://localhost:4175", + "http://127.0.0.1:4175", + "http://localhost:4176", + "http://127.0.0.1:4176", + "http://tauri.localhost", + }, cfg.AllowedOrigins) +} + func TestLoadFromDirDefaultsToDevYAML(t *testing.T) { configDir := t.TempDir() writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "dev-system", "dev-ai") @@ -47,6 +74,32 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) { require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins) } +func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) { + configDir := t.TempDir() + writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai") + t.Setenv("SENLIN_APP_MODE", "dev") + + _, err := LoadFromDir(configDir) + + require.ErrorContains(t, err, "storage_dir") +} + +func TestLoadFromDirRejectsMissingAllowedOrigins(t *testing.T) { + configDir := t.TempDir() + content := []byte("env: development\n" + + "port: \"9150\"\n" + + "dsn: \"postgres://agent\"\n" + + "storage_dir: \"./data/files\"\n" + + "auth_secret: \"dev-auth\"\n" + + "ai_key_encryption_secret: \"dev-ai\"\n") + require.NoError(t, os.WriteFile(filepath.Join(configDir, "agent.dev.yaml"), content, 0o600)) + t.Setenv("SENLIN_APP_MODE", "dev") + + _, err := LoadFromDir(configDir) + + require.ErrorContains(t, err, "allowed_origins") +} + func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) { t.Helper() allowedOrigins := " - http://localhost:5173\n - http://tauri.localhost\n" diff --git a/backend/internal/seed/demo.go b/backend/internal/seed/demo.go index fecfc2e..06a9155 100644 --- a/backend/internal/seed/demo.go +++ b/backend/internal/seed/demo.go @@ -110,7 +110,8 @@ func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, identifier string, description string) (models.SenlinAgentProject, error) { var project models.SenlinAgentProject - err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error + // identifier 是用户范围内的稳定唯一键;展示名称被本地修改后,重复 seed 仍复用原项目。 + err := tx.Where("owner_id = ? AND identifier = ?", ownerID, identifier).First(&project).Error if err == nil { return project, nil } diff --git a/backend/internal/seed/demo_test.go b/backend/internal/seed/demo_test.go index ef90984..aaa0f8c 100644 --- a/backend/internal/seed/demo_test.go +++ b/backend/internal/seed/demo_test.go @@ -7,6 +7,7 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" + "gorm.io/gorm/logger" "senlinai-agent/backend/internal/logic/auth" "senlinai-agent/backend/internal/logic/projects" "senlinai-agent/backend/internal/models" @@ -53,11 +54,80 @@ func TestDemoSeedIsIdempotent(t *testing.T) { var userCount int64 require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error) require.Equal(t, int64(1), userCount) + + var projectCount int64 + require.NoError(t, database.Model(&models.SenlinAgentProject{}).Where("owner_id = ?", first.User.ID).Count(&projectCount).Error) + require.Equal(t, int64(4), projectCount) + var distinctIdentifiers int64 + require.NoError(t, database.Model(&models.SenlinAgentProject{}). + Where("owner_id = ?", first.User.ID). + Distinct("identifier"). + Count(&distinctIdentifiers).Error) + require.Equal(t, projectCount, distinctIdentifiers) + + var tags []models.SenlinAgentTag + require.NoError(t, database.Where("project_id = ?", first.Projects[0].ID).Find(&tags).Error) + require.Len(t, tags, 4) + for _, tag := range tags { + require.Equal(t, first.Projects[0].ID, tag.ProjectID) + require.NotEmpty(t, tag.Identity) + } +} + +func TestDemoSeedUsesIdentifierAsStableProjectKey(t *testing.T) { + database := newTestDB(t) + + first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"}) + require.NoError(t, err) + original := first.Projects[0] + require.NoError(t, database.Model(&original).Update("name", "本地重命名项目").Error) + + second, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"}) + + require.NoError(t, err) + require.Equal(t, original.ID, second.Projects[0].ID) + require.Equal(t, "A1", second.Projects[0].Identifier) + var projectCount int64 + require.NoError(t, database.Model(&models.SenlinAgentProject{}). + Where("owner_id = ? AND identifier = ?", first.User.ID, "A1"). + Count(&projectCount).Error) + require.Equal(t, int64(1), projectCount) +} + +func TestDemoSeedKeepsSameNamedTagsScopedToTheirProjects(t *testing.T) { + database := newTestDB(t) + + first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"}) + require.NoError(t, err) + otherTag := models.SenlinAgentTag{ProjectID: first.Projects[1].ID, Name: "UI"} + require.NoError(t, database.Create(&otherTag).Error) + + _, err = Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"}) + + require.NoError(t, err) + for _, projectID := range []uint{first.Projects[0].ID, first.Projects[1].ID} { + var count int64 + require.NoError(t, database.Model(&models.SenlinAgentTag{}). + Where("project_id = ? AND name = ?", projectID, "UI"). + Count(&count).Error) + require.Equal(t, int64(1), count) + } + + var taggedTasks []models.SenlinAgentTask + require.NoError(t, database.Where("project_id = ? AND tag_id IS NOT NULL", first.Projects[0].ID).Find(&taggedTasks).Error) + require.NotEmpty(t, taggedTasks) + for _, task := range taggedTasks { + var tag models.SenlinAgentTag + require.NoError(t, database.First(&tag, *task.TagID).Error) + require.Equal(t, task.ProjectID, tag.ProjectID) + } } func newTestDB(t *testing.T) *gorm.DB { t.Helper() - database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 541678a..0122cad 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,3 +1,4 @@ +# 仅供本地开发使用;agent/agent 是非生产默认凭据。 services: postgres: image: postgres:16