diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 96a4353..f262fbd 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -394,7 +394,7 @@ jobs: # ── Determine environment ────────────────── - name: Determine environment - id: env + id: setup run: | # All environments share one host: switchboard.DOMAIN # Environments are separated by path prefix (BASE_PATH) @@ -472,20 +472,20 @@ jobs: PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }} APP_USER: ${{ secrets.POSTGRES_USER }} APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - DB_NAME: ${{ steps.env.outputs.DB_NAME }} + DB_NAME: ${{ steps.setup.outputs.DB_NAME }} run: | chmod +x scripts/db-bootstrap.sh scripts/db-bootstrap.sh # ── Dev: Upgrade Test (dev only) ─────────── - name: "Dev: test migration upgrade" - if: steps.env.outputs.DB_WIPE == 'true' + if: steps.setup.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - PGDATABASE: ${{ steps.env.outputs.DB_NAME }} + PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} run: | echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━" @@ -531,28 +531,28 @@ jobs: # ── Dev: Validate Schema ─────────────────── - name: "Dev: validate schema" - if: steps.env.outputs.DB_WIPE == 'true' + if: steps.setup.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - PGDATABASE: ${{ steps.env.outputs.DB_NAME }} + PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} run: | chmod +x scripts/db-validate.sh scripts/db-validate.sh # ── Dev Wipe (fresh install test) ────────── - name: "Dev: wipe for fresh install test" - if: steps.env.outputs.DB_WIPE == 'true' + if: steps.setup.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - PGDATABASE: ${{ steps.env.outputs.DB_NAME }} + PGDATABASE: ${{ steps.setup.outputs.DB_NAME }} run: | - DB="${{ steps.env.outputs.DB_NAME }}" + DB="${{ steps.setup.outputs.DB_NAME }}" if [[ "${DB}" != *_dev ]]; then echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped" @@ -575,46 +575,46 @@ jobs: - name: Build backend image run: | docker build -f server/Dockerfile \ - -t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ + -t ${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} \ . # ── Build Frontend Image ───────────────────── - name: Build frontend image run: | docker build -f Dockerfile.frontend \ - -t ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ + -t ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} \ . # ── Tag release versions ───────────────────── - name: Tag release versions - if: steps.env.outputs.EXTRA_TAG != '' + if: steps.setup.outputs.EXTRA_TAG != '' run: | - docker tag ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} - docker tag ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker tag ${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${BE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} + docker tag ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} # ── Push to Internal Registry ──────────────── - name: Push images run: | - docker push ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} - docker push ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} - if [[ -n "${{ steps.env.outputs.EXTRA_TAG }}" ]]; then - docker push ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} - docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker push ${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} + docker push ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} + if [[ -n "${{ steps.setup.outputs.EXTRA_TAG }}" ]]; then + docker push ${BE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} + docker push ${FE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} fi # ── Build + Push Unified to Docker Hub (release only) ── - name: Build and push unified image - if: steps.env.outputs.is_release == 'true' + if: steps.setup.outputs.is_release == 'true' run: | docker build -f Dockerfile \ -t ${DOCKERHUB_IMAGE}:latest \ - -t ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \ + -t ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} \ . if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin docker push ${DOCKERHUB_IMAGE}:latest - docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }} else echo "⚠ Docker Hub credentials not configured, skipping push" fi @@ -679,23 +679,23 @@ jobs: - name: Render and apply manifests env: - ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} - GIN_MODE: ${{ steps.env.outputs.GIN_MODE }} - IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} - DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} - DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} - BASE_PATH: ${{ steps.env.outputs.BASE_PATH }} - DB_NAME: ${{ steps.env.outputs.DB_NAME }} - FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }} - BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }} - FE_MEMORY_REQUEST: ${{ steps.env.outputs.FE_MEMORY_REQUEST }} - FE_MEMORY_LIMIT: ${{ steps.env.outputs.FE_MEMORY_LIMIT }} - FE_CPU_REQUEST: ${{ steps.env.outputs.FE_CPU_REQUEST }} - FE_CPU_LIMIT: ${{ steps.env.outputs.FE_CPU_LIMIT }} - BE_MEMORY_REQUEST: ${{ steps.env.outputs.BE_MEMORY_REQUEST }} - BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }} - BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }} - BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }} + ENVIRONMENT: ${{ steps.setup.outputs.ENVIRONMENT }} + GIN_MODE: ${{ steps.setup.outputs.GIN_MODE }} + IMAGE_TAG: ${{ steps.setup.outputs.IMAGE_TAG }} + DEPLOY_SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }} + DEPLOY_HOST: ${{ steps.setup.outputs.DEPLOY_HOST }} + BASE_PATH: ${{ steps.setup.outputs.BASE_PATH }} + DB_NAME: ${{ steps.setup.outputs.DB_NAME }} + FE_REPLICAS: ${{ steps.setup.outputs.FE_REPLICAS }} + BE_REPLICAS: ${{ steps.setup.outputs.BE_REPLICAS }} + FE_MEMORY_REQUEST: ${{ steps.setup.outputs.FE_MEMORY_REQUEST }} + FE_MEMORY_LIMIT: ${{ steps.setup.outputs.FE_MEMORY_LIMIT }} + FE_CPU_REQUEST: ${{ steps.setup.outputs.FE_CPU_REQUEST }} + FE_CPU_LIMIT: ${{ steps.setup.outputs.FE_CPU_LIMIT }} + BE_MEMORY_REQUEST: ${{ steps.setup.outputs.BE_MEMORY_REQUEST }} + BE_MEMORY_LIMIT: ${{ steps.setup.outputs.BE_MEMORY_LIMIT }} + BE_CPU_REQUEST: ${{ steps.setup.outputs.BE_CPU_REQUEST }} + BE_CPU_LIMIT: ${{ steps.setup.outputs.BE_CPU_LIMIT }} # Storage (v0.12.0+) STORAGE_CLASS: ${{ vars.STORAGE_CLASS }} STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }} @@ -727,8 +727,8 @@ jobs: - name: Rollout and verify env: - SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} - ENV: ${{ steps.env.outputs.ENVIRONMENT }} + SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }} + ENV: ${{ steps.setup.outputs.ENVIRONMENT }} run: | kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} @@ -753,36 +753,46 @@ jobs: # ── Post-deploy: verify schema ───────────── - name: Verify schema migration + env: + EXPECTED_DB: ${{ steps.setup.outputs.DB_NAME }} run: | sleep 5 - HOST="${{ steps.env.outputs.DEPLOY_HOST }}" - BP="${{ steps.env.outputs.BASE_PATH }}" - HEALTH=$(curl -sf "https://${HOST}${BP}/api/v1/health" 2>/dev/null || echo "unreachable") + HOST="${{ steps.setup.outputs.DEPLOY_HOST }}" + BP="${{ steps.setup.outputs.BASE_PATH }}" + HEALTH=$(curl -sf "https://${HOST}${BP}/health" 2>/dev/null || echo "unreachable") echo "Health: ${HEALTH}" SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true) DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || true) + ACTUAL_DB=$(echo "${HEALTH}" | grep -o '"database_name":"[^"]*"' | cut -d'"' -f4 || true) if [[ -n "${SCHEMA}" ]] && [[ -n "${DB_OK}" ]]; then - echo "✅ Schema: ${SCHEMA} | DB: connected" + echo "✅ Schema: ${SCHEMA} | DB: ${ACTUAL_DB}" else echo "⚠ Could not verify schema (backend may still be starting)" echo " Response: ${HEALTH:0:200}" fi + # Verify the backend connected to the correct database + if [[ -n "${ACTUAL_DB}" ]] && [[ "${ACTUAL_DB}" != "${EXPECTED_DB}" ]]; then + echo "❌ DATABASE MISMATCH: expected=${EXPECTED_DB} actual=${ACTUAL_DB}" + echo " The backend is connected to the WRONG database!" + exit 1 + fi + - name: Summary run: | - HOST="${{ steps.env.outputs.DEPLOY_HOST }}" - BP="${{ steps.env.outputs.BASE_PATH }}" - echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY + HOST="${{ steps.setup.outputs.DEPLOY_HOST }}" + BP="${{ steps.setup.outputs.BASE_PATH }}" + echo "## ✅ Deployed to ${{ steps.setup.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| | |" >> $GITHUB_STEP_SUMMARY echo "|---|---|" >> $GITHUB_STEP_SUMMARY echo "| **URL** | https://${HOST}${BP}/ |" >> $GITHUB_STEP_SUMMARY - echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Backend** | \`${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Database** | \`${{ steps.setup.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **DB Wipe** | ${{ steps.setup.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY @@ -792,6 +802,6 @@ jobs: else echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY fi - if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then - echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY + if [[ "${{ steps.setup.outputs.is_release }}" == "true" ]]; then + echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3fbb82b..b5c10ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,12 +28,12 @@ services: environment: PORT: "8080" BASE_PATH: "" - DB_HOST: postgres - DB_PORT: "5432" - DB_USER: switchboard - DB_PASSWORD: ${DB_PASSWORD:-switchboard_dev} - DB_NAME: chat_switchboard - DB_SSL_MODE: disable + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_USER: switchboard + POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev} + POSTGRES_DB: chat_switchboard + POSTGRES_SSLMODE: disable JWT_SECRET: ${JWT_SECRET:-change-me-in-production} SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} diff --git a/k8s/backend.yaml b/k8s/backend.yaml index d5850e4..3fc8904 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -85,7 +85,7 @@ spec: name: switchboard-db-credentials key: POSTGRES_PASSWORD - name: DATABASE_URL - value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable" + value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable" - name: SWITCHBOARD_ADMIN_USERNAME valueFrom: secretKeyRef: diff --git a/server/config/config.go b/server/config/config.go index ddf6b0f..28339dd 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strconv" @@ -60,7 +61,7 @@ func Load() *Config { return &Config{ Port: getEnv("PORT", "8080"), - DatabaseURL: getEnv("DATABASE_URL", ""), + DatabaseURL: resolveDatabaseURL(), DBDriver: getEnv("DB_DRIVER", ""), JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), Environment: getEnv("ENVIRONMENT", "development"), @@ -85,6 +86,31 @@ func Load() *Config { } } +// resolveDatabaseURL returns the database connection string. +// +// Resolution order: +// 1. DATABASE_URL — explicit DSN (K8s, .env, manual) +// 2. POSTGRES_* vars — assembled from individual env vars: +// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB +// (set by K8s manifest and docker-compose) +// 3. Empty string — backend runs without database +func resolveDatabaseURL() string { + if dsn := os.Getenv("DATABASE_URL"); dsn != "" { + return dsn + } + // Assemble from individual vars (K8s sets POSTGRES_*, docker-compose may too) + host := getEnv("POSTGRES_HOST", "") + if host == "" { + return "" + } + port := getEnv("POSTGRES_PORT", "5432") + user := getEnv("POSTGRES_USER", "") + pass := getEnv("POSTGRES_PASSWORD", "") + db := getEnv("POSTGRES_DB", "chat_switchboard") + ssl := getEnv("POSTGRES_SSLMODE", "disable") + return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, db, ssl) +} + // sanitizeBasePath ensures the path starts with / and doesn't end with /. // Empty string means root (no prefix). func sanitizeBasePath(p string) string { diff --git a/server/database/database.go b/server/database/database.go index bc8c904..37bc1a6 100644 --- a/server/database/database.go +++ b/server/database/database.go @@ -43,6 +43,10 @@ func connectPostgres(dsn string) error { return nil } + // Log the database name for operational clarity (never log credentials). + dbName = extractDBName(dsn) + log.Printf("Connecting to Postgres database %q ...", dbName) + var err error DB, err = sql.Open("postgres", dsn) if err != nil { @@ -57,7 +61,7 @@ func connectPostgres(dsn string) error { } CurrentDialect = DialectPostgres - log.Println("✅ Database connected (postgres)") + log.Printf("✅ Database connected (postgres, db=%s)", dbName) return nil } @@ -104,6 +108,7 @@ func connectSQLite(dsn string) error { // Verify WAL mode took effect (some in-memory modes ignore it). var mode string DB.QueryRow("PRAGMA journal_mode").Scan(&mode) + dbName = dsn log.Printf("✅ Database connected (sqlite, journal_mode=%s, path=%s)", mode, dsn) CurrentDialect = DialectSQLite @@ -125,6 +130,32 @@ func detectDriver(dsn string) string { return "postgres" } +// extractDBName pulls the database name from a DSN for logging. +// Handles both URL-style (postgres://user:pass@host/dbname) and +// keyword-style (host=... dbname=...) formats. Never returns creds. +func extractDBName(dsn string) string { + // URL style: postgres://user:pass@host:port/dbname?params + if strings.Contains(dsn, "://") { + idx := strings.LastIndex(dsn, "/") + if idx >= 0 && idx < len(dsn)-1 { + name := dsn[idx+1:] + if q := strings.Index(name, "?"); q >= 0 { + name = name[:q] + } + if name != "" { + return name + } + } + } + // Keyword style: host=... dbname=xyz ... + for _, part := range strings.Fields(dsn) { + if strings.HasPrefix(part, "dbname=") { + return strings.TrimPrefix(part, "dbname=") + } + } + return "(unknown)" +} + // Close gracefully shuts down the connection pool. func Close() { if DB != nil { @@ -139,3 +170,11 @@ func IsConnected() bool { } return DB.Ping() == nil } + +// dbName holds the database name extracted at connect time. +var dbName string + +// Name returns the database name (extracted from the DSN at connect time). +func Name() string { + return dbName +} diff --git a/server/database/migrations/010_v0211_workspace_bindings.sql b/server/database/migrations/010_v0211_workspace_bindings.sql new file mode 100644 index 0000000..60de559 --- /dev/null +++ b/server/database/migrations/010_v0211_workspace_bindings.sql @@ -0,0 +1,16 @@ +-- v0.21.1: Workspace bindings for channels and projects +-- +-- Channels and projects can optionally bind to a workspace. +-- Resolution chain: channel workspace_id > project workspace_id. + +-- Channel workspace binding +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; + +-- Project workspace binding +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id) WHERE workspace_id IS NOT NULL; diff --git a/server/database/migrations/sqlite/009_v0211_workspace_bindings.sql b/server/database/migrations/sqlite/009_v0211_workspace_bindings.sql new file mode 100644 index 0000000..04aaf02 --- /dev/null +++ b/server/database/migrations/sqlite/009_v0211_workspace_bindings.sql @@ -0,0 +1,9 @@ +-- v0.21.1: Workspace bindings for channels and projects (SQLite) + +ALTER TABLE channels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id); + +ALTER TABLE projects ADD COLUMN workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id); diff --git a/server/handlers/completion.go b/server/handlers/completion.go index cdd2a65..19ebbdc 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -219,6 +219,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } + // ── Workspace resolution (v0.21.1) ──────── + // Resolve effective workspace: channel.workspace_id > project.workspace_id + workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) + // ── Multi-model @mention routing (v0.20.0) ────────── // Check if the message @mentions specific channel models. // If multiple models are targeted, fan out sequentially. @@ -227,7 +231,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { parsed := mentions.Parse(req.Content, roster) targets := mentions.ResolvedModels(parsed) if len(targets) > 1 { - h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req) + h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, workspaceID, req) return } } @@ -255,7 +259,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { // Attach tool definitions if model supports tool calling and tools are available hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools) + provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID) } // Determine streaming @@ -265,9 +269,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } if stream { - h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID) + h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID, workspaceID) } else { - h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID) + h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID, workspaceID) } } @@ -280,7 +284,7 @@ func (h *CompletionHandler) multiModelStream( c *gin.Context, targets []models.ChannelModel, messages []providers.Message, - channelID, userID, personaID, presetSystemPrompt string, + channelID, userID, personaID, presetSystemPrompt, workspaceID string, req completionRequest, ) { // Set SSE headers once for the entire multi-model stream @@ -363,11 +367,11 @@ func (h *CompletionHandler) multiModelStream( } hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools) + provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID) } // Stream this model's response using the shared streaming core - result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub) + result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, workspaceID, h.hub) // Persist assistant message with model attribution if result.Content != "" { @@ -390,13 +394,21 @@ func (h *CompletionHandler) multiModelStream( // buildToolDefs converts registered tools to provider-format tool definitions. // If includeBrowser is true, also fetches tool schemas from enabled browser extensions. // Tools whose names appear in disabledTools are excluded. -func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string) []providers.ToolDef { +// Workspace tools are excluded when workspaceID is empty (no workspace bound). +func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, workspaceID string) []providers.ToolDef { // Build disabled set for O(1) lookup disabled := make(map[string]bool, len(disabledTools)) for _, name := range disabledTools { disabled[name] = true } + // If no workspace is bound, disable workspace tools + if workspaceID == "" { + for _, name := range tools.WorkspaceToolNames() { + disabled[name] = true + } + } + allTools := tools.AllDefinitionsFiltered(disabled) defs := make([]providers.ToolDef, 0, len(allTools)) for _, t := range allTools { @@ -519,9 +531,9 @@ func (h *CompletionHandler) streamCompletion( provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, - channelID, userID, model, configID, providerScope, personaID string, + channelID, userID, model, configID, providerScope, personaID, workspaceID string, ) { - result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, h.hub) + result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, workspaceID, h.hub) // Persist assistant response if result.Content != "" { @@ -543,7 +555,7 @@ func (h *CompletionHandler) syncCompletion( provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, - channelID, userID, model, configID, providerScope, personaID string, + channelID, userID, model, configID, providerScope, personaID, workspaceID string, ) { var totalInput, totalOutput int var totalCacheCreation, totalCacheRead int @@ -607,9 +619,10 @@ func (h *CompletionHandler) syncCompletion( req.Messages = append(req.Messages, assistantMsg) execCtx := tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, + UserID: userID, + ChannelID: channelID, + PersonaID: personaID, + WorkspaceID: workspaceID, } for _, tc := range resp.ToolCalls { call := tools.ToolCall{ diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 1861d68..25e2b92 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -508,14 +508,15 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { } // Attach tool definitions (same as normal completion) + workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools) + provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID) } // ── Stream the response (shared loop handles tools, reasoning, SSE) ── - result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, h.hub) + result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, workspaceID, h.hub) // Persist as sibling (regen) with tool activity if result.Content != "" { diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go index 32bd875..ad78e4b 100644 --- a/server/handlers/stream_loop.go +++ b/server/handlers/stream_loop.go @@ -43,7 +43,7 @@ func streamWithToolLoop( provider providers.Provider, cfg providers.ProviderConfig, req *providers.CompletionRequest, - model, userID, channelID, personaID string, + model, userID, channelID, personaID, workspaceID string, hub *events.Hub, ) streamResult { // Set SSE headers @@ -166,9 +166,10 @@ func streamWithToolLoop( // Execute all tool calls execCtx := tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, + UserID: userID, + ChannelID: channelID, + PersonaID: personaID, + WorkspaceID: workspaceID, } for _, tc := range toolCalls { call := tools.ToolCall{ @@ -251,7 +252,7 @@ func streamModelResponse( provider providers.Provider, cfg providers.ProviderConfig, req *providers.CompletionRequest, - model, displayName, userID, channelID, personaID string, + model, displayName, userID, channelID, personaID, workspaceID string, hub *events.Hub, ) streamResult { flusher, _ := c.Writer.(http.Flusher) @@ -354,9 +355,10 @@ func streamModelResponse( req.Messages = append(req.Messages, assistantMsg) execCtx := tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, + UserID: userID, + ChannelID: channelID, + PersonaID: personaID, + WorkspaceID: workspaceID, } for _, tc := range toolCalls { call := tools.ToolCall{ diff --git a/server/main.go b/server/main.go index 7da49c0..9d1db31 100644 --- a/server/main.go +++ b/server/main.go @@ -165,6 +165,11 @@ func main() { } } + // Register workspace tools (late registration — needs stores + wfs) + if wfs != nil { + tools.RegisterWorkspaceTools(stores, wfs) + } + // Role resolver for model role dispatch (needs stores + vault + bus) roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus) @@ -236,6 +241,7 @@ func main() { "status": "ok", "version": Version, "database": database.IsConnected(), + "database_name": database.Name(), "schema_version": database.SchemaVersion(), }) }) @@ -256,6 +262,7 @@ func main() { "version": Version, "schema_version": database.SchemaVersion(), "database": database.IsConnected(), + "database_name": database.Name(), "providers": providers.List(), } if database.IsConnected() { diff --git a/server/models/models.go b/server/models/models.go index 6bef79b..c59d7fe 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -309,6 +309,7 @@ type Channel struct { FolderID *string `json:"folder_id,omitempty" db:"folder_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"` ProjectID *string `json:"project_id,omitempty" db:"project_id"` + WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` Settings JSONMap `json:"settings,omitempty" db:"settings"` } @@ -385,6 +386,7 @@ type Project struct { OwnerID string `json:"owner_id" db:"owner_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"` IsArchived bool `json:"is_archived" db:"is_archived"` + WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` Settings JSONMap `json:"settings,omitempty" db:"settings"` // Computed fields (not DB columns) @@ -400,6 +402,7 @@ type ProjectPatch struct { Color *string `json:"color,omitempty"` Icon *string `json:"icon,omitempty"` IsArchived *bool `json:"is_archived,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` Settings JSONMap `json:"settings,omitempty"` } diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 534bc97..968c4af 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -225,6 +225,9 @@ type ChannelStore interface { // Ownership check UserOwns(ctx context.Context, channelID, userID string) (bool, error) + + // Workspace resolution: channel workspace_id > project workspace_id + ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) } // ========================================= diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go index 597957f..0366e81 100644 --- a/server/store/postgres/channel.go +++ b/server/store/postgres/channel.go @@ -266,3 +266,24 @@ func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) ( channelID, userID).Scan(&exists) return exists, err } + +// ResolveWorkspaceID returns the effective workspace for a channel. +// Resolution: channel.workspace_id > project.workspace_id. +// Returns empty string if no workspace is bound. +func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) { + var wsID sql.NullString + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(c.workspace_id, p.workspace_id) + FROM channels c + LEFT JOIN project_channels pc ON pc.channel_id = c.id + LEFT JOIN projects p ON p.id = pc.project_id + WHERE c.id = $1 + LIMIT 1`, channelID).Scan(&wsID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + return NullableString(wsID), nil +} diff --git a/server/store/postgres/project.go b/server/store/postgres/project.go index bea24da..ddd2027 100644 --- a/server/store/postgres/project.go +++ b/server/store/postgres/project.go @@ -30,10 +30,10 @@ func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { var p models.Project - var teamID sql.NullString + var teamID, workspaceID sql.NullString err := DB.QueryRowContext(ctx, ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.settings, + p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), @@ -41,7 +41,7 @@ func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, FROM projects p WHERE p.id = $1`, id).Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &p.IsArchived, &p.Settings, + &p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.ChannelCount, &p.KBCount, &p.NoteCount, ) @@ -49,6 +49,7 @@ func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, return nil, err } p.TeamID = NullableStringPtr(teamID) + p.WorkspaceID = NullableStringPtr(workspaceID) return &p, nil } @@ -69,6 +70,9 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje if patch.IsArchived != nil { b.Set("is_archived", *patch.IsArchived) } + if patch.WorkspaceID != nil { + b.Set("workspace_id", models.NullString(patch.WorkspaceID)) + } if len(patch.Settings) > 0 { // Merge: read existing settings, overlay with patch values, write back. var existing models.JSONMap @@ -114,7 +118,7 @@ func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs [ // User sees: their personal projects + team projects for their teams + global projects q := ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.settings, + p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), @@ -403,16 +407,17 @@ func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models var result []models.Project for rows.Next() { var p models.Project - var teamID sql.NullString + var teamID, workspaceID sql.NullString if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &p.IsArchived, &p.Settings, + &p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.ChannelCount, &p.KBCount, &p.NoteCount, ); err != nil { return nil, err } p.TeamID = NullableStringPtr(teamID) + p.WorkspaceID = NullableStringPtr(workspaceID) result = append(result, p) } return result, rows.Err() diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go index 0cdae04..d735444 100644 --- a/server/store/sqlite/channel.go +++ b/server/store/sqlite/channel.go @@ -268,3 +268,24 @@ func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) ( channelID, userID).Scan(&exists) return exists, err } + +// ResolveWorkspaceID returns the effective workspace for a channel. +// Resolution: channel.workspace_id > project.workspace_id. +// Returns empty string if no workspace is bound. +func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) { + var wsID sql.NullString + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(c.workspace_id, p.workspace_id) + FROM channels c + LEFT JOIN project_channels pc ON pc.channel_id = c.id + LEFT JOIN projects p ON p.id = pc.project_id + WHERE c.id = ? + LIMIT 1`, channelID).Scan(&wsID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + return NullableString(wsID), nil +} diff --git a/server/store/sqlite/project.go b/server/store/sqlite/project.go index 5cd622c..474dd22 100644 --- a/server/store/sqlite/project.go +++ b/server/store/sqlite/project.go @@ -37,11 +37,11 @@ func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { var p models.Project - var teamID sql.NullString + var teamID, workspaceID sql.NullString var archived int err := DB.QueryRowContext(ctx, ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.settings, + p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), @@ -49,7 +49,7 @@ func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, FROM projects p WHERE p.id = ?`, id).Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &archived, &p.Settings, + &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt), &p.ChannelCount, &p.KBCount, &p.NoteCount, ) @@ -58,6 +58,7 @@ func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, } p.IsArchived = archived != 0 p.TeamID = NullableStringPtr(teamID) + p.WorkspaceID = NullableStringPtr(workspaceID) return &p, nil } @@ -85,6 +86,9 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje if patch.IsArchived != nil { add("is_archived", boolToInt(*patch.IsArchived)) } + if patch.WorkspaceID != nil { + add("workspace_id", models.NullString(patch.WorkspaceID)) + } if len(patch.Settings) > 0 { // Merge: read existing settings, overlay with patch values, write back. var existing models.JSONMap @@ -142,7 +146,7 @@ func (s *ProjectStore) Delete(ctx context.Context, id string) error { func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { q := ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.settings, + p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), @@ -433,11 +437,11 @@ func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...inte var result []models.Project for rows.Next() { var p models.Project - var teamID sql.NullString + var teamID, workspaceID sql.NullString var archived int if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &archived, &p.Settings, + &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt), &p.ChannelCount, &p.KBCount, &p.NoteCount, ); err != nil { @@ -445,6 +449,7 @@ func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...inte } p.IsArchived = archived != 0 p.TeamID = NullableStringPtr(teamID) + p.WorkspaceID = NullableStringPtr(workspaceID) result = append(result, p) } return result, rows.Err() diff --git a/server/tools/types.go b/server/tools/types.go index b359e2e..ea02426 100644 --- a/server/tools/types.go +++ b/server/tools/types.go @@ -41,9 +41,10 @@ type ToolResult struct { // ExecutionContext provides tool implementations with the info they need. type ExecutionContext struct { - UserID string - ChannelID string - PersonaID string // set when a persona is active — tools use for scoped KB access + UserID string + ChannelID string + PersonaID string // set when a persona is active — tools use for scoped KB access + WorkspaceID string // set when channel/project has a workspace bound } // Tool is the interface every built-in tool implements. diff --git a/server/tools/workspace.go b/server/tools/workspace.go new file mode 100644 index 0000000..3fd1a12 --- /dev/null +++ b/server/tools/workspace.go @@ -0,0 +1,497 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" + "git.gobha.me/xcaliber/chat-switchboard/workspace" +) + +// ── Late Registration ──────────────────────── +// Workspace tools need the workspace.FS and store for execution. +// Registered from main.go after workspace FS init. + +// RegisterWorkspaceTools registers all workspace tools with captured dependencies. +func RegisterWorkspaceTools(stores store.Stores, wfs *workspace.FS) { + Register(&workspaceLsTool{stores: stores, wfs: wfs}) + Register(&workspaceReadTool{stores: stores, wfs: wfs}) + Register(&workspaceWriteTool{stores: stores, wfs: wfs}) + Register(&workspaceRmTool{stores: stores, wfs: wfs}) + Register(&workspaceMvTool{stores: stores, wfs: wfs}) + Register(&workspacePatchTool{stores: stores, wfs: wfs}) +} + +// WorkspaceToolNames returns the names of all workspace tools. +// Used by the completion handler to filter them out when no workspace is bound. +func WorkspaceToolNames() []string { + return []string{ + "workspace_ls", "workspace_read", "workspace_write", + "workspace_rm", "workspace_mv", "workspace_patch", + } +} + +// ── Shared ───────────────────────────────── + +// loadWorkspace resolves the workspace from the execution context. +func loadWorkspace(ctx context.Context, stores store.Stores, execCtx ExecutionContext) (*models.Workspace, error) { + if execCtx.WorkspaceID == "" { + return nil, fmt.Errorf("no workspace bound to this channel") + } + w, err := stores.Workspaces.GetByID(ctx, execCtx.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("workspace not found: %w", err) + } + return w, nil +} + +const ( + // maxReadBytes is the soft limit for workspace_read (50 KB). + maxReadBytes = 50 * 1024 + // maxPatchFileBytes is the limit for files that can be patched (1 MB). + maxPatchFileBytes = 1024 * 1024 +) + +// ═══════════════════════════════════════════ +// workspace_ls +// ═══════════════════════════════════════════ + +type workspaceLsTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspaceLsTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_ls", + DisplayName: "List Files", + Description: "List files and directories in the workspace. Returns name, type, size, and content type for each entry. Use with path=\"\" or path=\".\" to list root.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "path": Prop("string", "Directory path to list (empty or \".\" for root)"), + "recursive": Prop("boolean", "If true, list all files recursively. Default false."), + }, nil), + } +} + +func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + entries, err := t.wfs.ListDir(ctx, w, args.Path, args.Recursive) + if err != nil { + return "", fmt.Errorf("list failed: %w", err) + } + + type entry struct { + Path string `json:"path"` + IsDir bool `json:"is_directory"` + ContentType string `json:"content_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + } + + result := make([]entry, 0, len(entries)) + for _, e := range entries { + result = append(result, entry{ + Path: e.Path, + IsDir: e.IsDirectory, + ContentType: e.ContentType, + SizeBytes: e.SizeBytes, + }) + } + + b, _ := json.Marshal(map[string]interface{}{ + "path": args.Path, + "entries": result, + "count": len(result), + }) + return string(b), nil +} + +// ═══════════════════════════════════════════ +// workspace_read +// ═══════════════════════════════════════════ + +type workspaceReadTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspaceReadTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_read", + DisplayName: "Read File", + Description: "Read the contents of a file in the workspace. Text files are returned as content; binary files return a summary. Large files are truncated at ~50KB with an indicator.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "path": Prop("string", "File path relative to workspace root"), + }, []string{"path"}), + } +} + +func (t *workspaceReadTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if args.Path == "" { + return "", fmt.Errorf("path is required") + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + // Stat first for content type and directory check + info, err := t.wfs.Stat(ctx, w, args.Path) + if err != nil { + return "", fmt.Errorf("file not found: %w", err) + } + if info.IsDirectory { + return "", fmt.Errorf("cannot read a directory; use workspace_ls instead") + } + + // Binary detection + if !isTextContentType(info.ContentType) { + b, _ := json.Marshal(map[string]interface{}{ + "path": args.Path, + "content_type": info.ContentType, + "size_bytes": info.SizeBytes, + "binary": true, + "message": fmt.Sprintf("Binary file (%s), %d bytes", info.ContentType, info.SizeBytes), + }) + return string(b), nil + } + + // Read content + r, size, err := t.wfs.ReadFile(ctx, w, args.Path) + if err != nil { + return "", fmt.Errorf("read failed: %w", err) + } + defer r.Close() + + // Read with truncation guard + buf := make([]byte, maxReadBytes+1) + n, readErr := io.ReadFull(r, buf) + if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { + return "", fmt.Errorf("read error: %w", readErr) + } + + truncated := n > maxReadBytes + if truncated { + n = maxReadBytes + } + + resp := map[string]interface{}{ + "path": args.Path, + "content": string(buf[:n]), + "content_type": info.ContentType, + "size_bytes": size, + } + if truncated { + resp["truncated"] = true + resp["message"] = fmt.Sprintf("File truncated at %d bytes (total: %d bytes)", maxReadBytes, size) + } + + b, _ := json.Marshal(resp) + return string(b), nil +} + +// isTextContentType returns true for text/* and known source code types. +func isTextContentType(ct string) bool { + if strings.HasPrefix(ct, "text/") { + return true + } + switch ct { + case "application/json", "application/xml", "application/javascript", + "application/x-yaml", "application/toml", "application/x-sh", + "application/sql", "application/graphql": + return true + } + return false +} + +// ═══════════════════════════════════════════ +// workspace_write +// ═══════════════════════════════════════════ + +type workspaceWriteTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspaceWriteTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_write", + DisplayName: "Write File", + Description: "Create or overwrite a file in the workspace. Parent directories are created automatically. Returns the path and size of the written file.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "path": Prop("string", "File path relative to workspace root"), + "content": Prop("string", "File content to write"), + }, []string{"path", "content"}), + } +} + +func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if args.Path == "" { + return "", fmt.Errorf("path is required") + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(args.Content), int64(len(args.Content))); err != nil { + return "", fmt.Errorf("write failed: %w", err) + } + + size := len(args.Content) + log.Printf("📝 workspace_write: %s (%d bytes)", args.Path, size) + + b, _ := json.Marshal(map[string]interface{}{ + "path": args.Path, + "size_bytes": size, + "message": fmt.Sprintf("Written %d bytes to %s", size, args.Path), + }) + return string(b), nil +} + +// ═══════════════════════════════════════════ +// workspace_rm +// ═══════════════════════════════════════════ + +type workspaceRmTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspaceRmTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_rm", + DisplayName: "Delete File", + Description: "Delete a file or directory from the workspace. For non-empty directories, set recursive=true. This cannot be undone.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "path": Prop("string", "File or directory path to delete"), + "recursive": Prop("boolean", "Required for non-empty directories. Default false."), + }, []string{"path"}), + } +} + +func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if args.Path == "" { + return "", fmt.Errorf("path is required") + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + if err := t.wfs.DeleteFile(ctx, w, args.Path, args.Recursive); err != nil { + return "", fmt.Errorf("delete failed: %w", err) + } + + log.Printf("🗑️ workspace_rm: %s (recursive=%v)", args.Path, args.Recursive) + + b, _ := json.Marshal(map[string]interface{}{ + "path": args.Path, + "message": fmt.Sprintf("Deleted %s", args.Path), + }) + return string(b), nil +} + +// ═══════════════════════════════════════════ +// workspace_mv +// ═══════════════════════════════════════════ + +type workspaceMvTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspaceMvTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_mv", + DisplayName: "Move/Rename File", + Description: "Move or rename a file or directory within the workspace. Destination parent directories are created automatically.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "source": Prop("string", "Current file path"), + "destination": Prop("string", "New file path"), + }, []string{"source", "destination"}), + } +} + +func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Source string `json:"source"` + Destination string `json:"destination"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if args.Source == "" || args.Destination == "" { + return "", fmt.Errorf("source and destination are required") + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + if err := t.wfs.MoveFile(ctx, w, args.Source, args.Destination); err != nil { + return "", fmt.Errorf("move failed: %w", err) + } + + log.Printf("📦 workspace_mv: %s → %s", args.Source, args.Destination) + + b, _ := json.Marshal(map[string]interface{}{ + "source": args.Source, + "destination": args.Destination, + "message": fmt.Sprintf("Moved %s → %s", args.Source, args.Destination), + }) + return string(b), nil +} + +// ═══════════════════════════════════════════ +// workspace_patch +// ═══════════════════════════════════════════ + +type workspacePatchTool struct { + stores store.Stores + wfs *workspace.FS +} + +func (t *workspacePatchTool) Definition() ToolDef { + return ToolDef{ + Name: "workspace_patch", + DisplayName: "Patch File", + Description: "Apply find-and-replace operations to a text file. Each operation's 'find' string must match exactly once in the file. Operations are applied sequentially. Use empty 'replace' to delete matched text.", + Category: "workspace", + Parameters: JSONSchema(map[string]interface{}{ + "path": Prop("string", "File path to patch"), + "operations": map[string]interface{}{ + "type": "array", + "description": "List of find/replace operations to apply sequentially", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "find": Prop("string", "Exact string to find (must match exactly once)"), + "replace": Prop("string", "String to replace with (empty to delete)"), + }, + "required": []string{"find", "replace"}, + }, + }, + }, []string{"path", "operations"}), + } +} + +func (t *workspacePatchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { + var args struct { + Path string `json:"path"` + Operations []struct { + Find string `json:"find"` + Replace string `json:"replace"` + } `json:"operations"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid arguments: %w", err) + } + if args.Path == "" { + return "", fmt.Errorf("path is required") + } + if len(args.Operations) == 0 { + return "", fmt.Errorf("at least one operation is required") + } + + w, err := loadWorkspace(ctx, t.stores, execCtx) + if err != nil { + return "", err + } + + // Read current content + r, size, err := t.wfs.ReadFile(ctx, w, args.Path) + if err != nil { + return "", fmt.Errorf("read failed: %w", err) + } + defer r.Close() + + if size > maxPatchFileBytes { + return "", fmt.Errorf("file too large for patching (%d bytes, max %d)", size, maxPatchFileBytes) + } + + data, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("read error: %w", err) + } + content := string(data) + + // Apply operations sequentially + applied := make([]string, 0, len(args.Operations)) + for i, op := range args.Operations { + if op.Find == "" { + return "", fmt.Errorf("operation %d: find string is empty", i+1) + } + + count := strings.Count(content, op.Find) + if count == 0 { + return "", fmt.Errorf("operation %d: find string not found in file", i+1) + } + if count > 1 { + return "", fmt.Errorf("operation %d: find string matches %d times (must match exactly once)", i+1, count) + } + + content = strings.Replace(content, op.Find, op.Replace, 1) + applied = append(applied, fmt.Sprintf("op %d: replaced %d chars → %d chars", i+1, len(op.Find), len(op.Replace))) + } + + // Write back + if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(content), int64(len(content))); err != nil { + return "", fmt.Errorf("write failed: %w", err) + } + + log.Printf("🔧 workspace_patch: %s (%d ops, %d bytes)", args.Path, len(args.Operations), len(content)) + + b, _ := json.Marshal(map[string]interface{}{ + "path": args.Path, + "operations": applied, + "size_bytes": len(content), + "message": fmt.Sprintf("Applied %d patch(es) to %s", len(args.Operations), args.Path), + }) + return string(b), nil +} diff --git a/server/workspace/fs.go b/server/workspace/fs.go index b6d441d..a3071c4 100644 --- a/server/workspace/fs.go +++ b/server/workspace/fs.go @@ -301,6 +301,78 @@ func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) er }) } +// MoveFile renames or moves a file within the workspace. +// Creates destination parent directories as needed. +func (fs *FS) MoveFile(ctx context.Context, w *models.Workspace, srcRel, dstRel string) error { + srcRel = cleanPath(srcRel) + dstRel = cleanPath(dstRel) + if srcRel == "" || dstRel == "" { + return fmt.Errorf("workspace: source and destination paths required") + } + if srcRel == dstRel { + return nil // no-op + } + + srcAbs, err := fs.absPath(w, srcRel) + if err != nil { + return err + } + dstAbs, err := fs.absPath(w, dstRel) + if err != nil { + return err + } + + // Ensure source exists + srcInfo, err := os.Stat(srcAbs) + if err != nil { + return fmt.Errorf("workspace: source not found: %s", srcRel) + } + + // Ensure destination parent exists + if err := os.MkdirAll(filepath.Dir(dstAbs), 0750); err != nil { + return fmt.Errorf("workspace: mkdir for move: %w", err) + } + + // Rename on disk + if err := os.Rename(srcAbs, dstAbs); err != nil { + return fmt.Errorf("workspace: rename %s → %s: %w", srcRel, dstRel, err) + } + + // Update DB index: delete old, upsert new + if srcInfo.IsDir() { + // For directories, delete all files under old prefix and reconcile new prefix. + // Simple approach: delete old entries, let next reconcile pick up new ones. + if err := fs.store.DeleteFilesByPrefix(ctx, w.ID, srcRel+"/"); err != nil { + log.Printf("workspace move: cleanup old prefix %s: %v", srcRel, err) + } + fs.store.DeleteFile(ctx, w.ID, srcRel) + // Upsert new directory entry + fs.store.UpsertFile(ctx, &models.WorkspaceFile{ + WorkspaceID: w.ID, + Path: dstRel, + IsDirectory: true, + }) + } else { + // Read metadata from old entry if available + oldFile, _ := fs.store.GetFile(ctx, w.ID, srcRel) + fs.store.DeleteFile(ctx, w.ID, srcRel) + + newFile := &models.WorkspaceFile{ + WorkspaceID: w.ID, + Path: dstRel, + IsDirectory: false, + SizeBytes: srcInfo.Size(), + ContentType: detectContentType(dstRel, dstAbs), + } + if oldFile != nil { + newFile.SHA256 = oldFile.SHA256 + } + fs.store.UpsertFile(ctx, newFile) + } + + return nil +} + // Stat returns file metadata without reading content. func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) { relPath = cleanPath(relPath)