Changeset 0.21.1.1 (#87)
This commit is contained in:
@@ -394,7 +394,7 @@ jobs:
|
|||||||
|
|
||||||
# ── Determine environment ──────────────────
|
# ── Determine environment ──────────────────
|
||||||
- name: Determine environment
|
- name: Determine environment
|
||||||
id: env
|
id: setup
|
||||||
run: |
|
run: |
|
||||||
# All environments share one host: switchboard.DOMAIN
|
# All environments share one host: switchboard.DOMAIN
|
||||||
# Environments are separated by path prefix (BASE_PATH)
|
# Environments are separated by path prefix (BASE_PATH)
|
||||||
@@ -472,20 +472,20 @@ jobs:
|
|||||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||||
APP_USER: ${{ secrets.POSTGRES_USER }}
|
APP_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
|
DB_NAME: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/db-bootstrap.sh
|
chmod +x scripts/db-bootstrap.sh
|
||||||
scripts/db-bootstrap.sh
|
scripts/db-bootstrap.sh
|
||||||
|
|
||||||
# ── Dev: Upgrade Test (dev only) ───────────
|
# ── Dev: Upgrade Test (dev only) ───────────
|
||||||
- name: "Dev: test migration upgrade"
|
- name: "Dev: test migration upgrade"
|
||||||
if: steps.env.outputs.DB_WIPE == 'true'
|
if: steps.setup.outputs.DB_WIPE == 'true'
|
||||||
env:
|
env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
|
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
run: |
|
||||||
echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━"
|
echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━"
|
||||||
|
|
||||||
@@ -531,28 +531,28 @@ jobs:
|
|||||||
|
|
||||||
# ── Dev: Validate Schema ───────────────────
|
# ── Dev: Validate Schema ───────────────────
|
||||||
- name: "Dev: validate schema"
|
- name: "Dev: validate schema"
|
||||||
if: steps.env.outputs.DB_WIPE == 'true'
|
if: steps.setup.outputs.DB_WIPE == 'true'
|
||||||
env:
|
env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
|
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/db-validate.sh
|
chmod +x scripts/db-validate.sh
|
||||||
scripts/db-validate.sh
|
scripts/db-validate.sh
|
||||||
|
|
||||||
# ── Dev Wipe (fresh install test) ──────────
|
# ── Dev Wipe (fresh install test) ──────────
|
||||||
- name: "Dev: wipe for 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:
|
env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
|
PGDATABASE: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
run: |
|
||||||
DB="${{ steps.env.outputs.DB_NAME }}"
|
DB="${{ steps.setup.outputs.DB_NAME }}"
|
||||||
|
|
||||||
if [[ "${DB}" != *_dev ]]; then
|
if [[ "${DB}" != *_dev ]]; then
|
||||||
echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped"
|
echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped"
|
||||||
@@ -575,46 +575,46 @@ jobs:
|
|||||||
- name: Build backend image
|
- name: Build backend image
|
||||||
run: |
|
run: |
|
||||||
docker build -f server/Dockerfile \
|
docker build -f server/Dockerfile \
|
||||||
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
-t ${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} \
|
||||||
.
|
.
|
||||||
|
|
||||||
# ── Build Frontend Image ─────────────────────
|
# ── Build Frontend Image ─────────────────────
|
||||||
- name: Build frontend image
|
- name: Build frontend image
|
||||||
run: |
|
run: |
|
||||||
docker build -f Dockerfile.frontend \
|
docker build -f Dockerfile.frontend \
|
||||||
-t ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
-t ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} \
|
||||||
.
|
.
|
||||||
|
|
||||||
# ── Tag release versions ─────────────────────
|
# ── Tag release versions ─────────────────────
|
||||||
- name: Tag release versions
|
- name: Tag release versions
|
||||||
if: steps.env.outputs.EXTRA_TAG != ''
|
if: steps.setup.outputs.EXTRA_TAG != ''
|
||||||
run: |
|
run: |
|
||||||
docker tag ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} ${BE_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.env.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker tag ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${FE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
|
|
||||||
# ── Push to Internal Registry ────────────────
|
# ── Push to Internal Registry ────────────────
|
||||||
- name: Push images
|
- name: Push images
|
||||||
run: |
|
run: |
|
||||||
docker push ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}
|
docker push ${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}
|
||||||
docker push ${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}
|
docker push ${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}
|
||||||
if [[ -n "${{ steps.env.outputs.EXTRA_TAG }}" ]]; then
|
if [[ -n "${{ steps.setup.outputs.EXTRA_TAG }}" ]]; then
|
||||||
docker push ${BE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker push ${BE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker push ${FE_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Build + Push Unified to Docker Hub (release only) ──
|
# ── Build + Push Unified to Docker Hub (release only) ──
|
||||||
- name: Build and push unified image
|
- name: Build and push unified image
|
||||||
if: steps.env.outputs.is_release == 'true'
|
if: steps.setup.outputs.is_release == 'true'
|
||||||
run: |
|
run: |
|
||||||
docker build -f Dockerfile \
|
docker build -f Dockerfile \
|
||||||
-t ${DOCKERHUB_IMAGE}:latest \
|
-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
|
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
|
||||||
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
|
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||||
docker push ${DOCKERHUB_IMAGE}:latest
|
docker push ${DOCKERHUB_IMAGE}:latest
|
||||||
docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
else
|
else
|
||||||
echo "⚠ Docker Hub credentials not configured, skipping push"
|
echo "⚠ Docker Hub credentials not configured, skipping push"
|
||||||
fi
|
fi
|
||||||
@@ -679,23 +679,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Render and apply manifests
|
- name: Render and apply manifests
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
|
ENVIRONMENT: ${{ steps.setup.outputs.ENVIRONMENT }}
|
||||||
GIN_MODE: ${{ steps.env.outputs.GIN_MODE }}
|
GIN_MODE: ${{ steps.setup.outputs.GIN_MODE }}
|
||||||
IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }}
|
IMAGE_TAG: ${{ steps.setup.outputs.IMAGE_TAG }}
|
||||||
DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
|
DEPLOY_SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
||||||
DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }}
|
DEPLOY_HOST: ${{ steps.setup.outputs.DEPLOY_HOST }}
|
||||||
BASE_PATH: ${{ steps.env.outputs.BASE_PATH }}
|
BASE_PATH: ${{ steps.setup.outputs.BASE_PATH }}
|
||||||
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
|
DB_NAME: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }}
|
FE_REPLICAS: ${{ steps.setup.outputs.FE_REPLICAS }}
|
||||||
BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }}
|
BE_REPLICAS: ${{ steps.setup.outputs.BE_REPLICAS }}
|
||||||
FE_MEMORY_REQUEST: ${{ steps.env.outputs.FE_MEMORY_REQUEST }}
|
FE_MEMORY_REQUEST: ${{ steps.setup.outputs.FE_MEMORY_REQUEST }}
|
||||||
FE_MEMORY_LIMIT: ${{ steps.env.outputs.FE_MEMORY_LIMIT }}
|
FE_MEMORY_LIMIT: ${{ steps.setup.outputs.FE_MEMORY_LIMIT }}
|
||||||
FE_CPU_REQUEST: ${{ steps.env.outputs.FE_CPU_REQUEST }}
|
FE_CPU_REQUEST: ${{ steps.setup.outputs.FE_CPU_REQUEST }}
|
||||||
FE_CPU_LIMIT: ${{ steps.env.outputs.FE_CPU_LIMIT }}
|
FE_CPU_LIMIT: ${{ steps.setup.outputs.FE_CPU_LIMIT }}
|
||||||
BE_MEMORY_REQUEST: ${{ steps.env.outputs.BE_MEMORY_REQUEST }}
|
BE_MEMORY_REQUEST: ${{ steps.setup.outputs.BE_MEMORY_REQUEST }}
|
||||||
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
|
BE_MEMORY_LIMIT: ${{ steps.setup.outputs.BE_MEMORY_LIMIT }}
|
||||||
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
|
BE_CPU_REQUEST: ${{ steps.setup.outputs.BE_CPU_REQUEST }}
|
||||||
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
|
BE_CPU_LIMIT: ${{ steps.setup.outputs.BE_CPU_LIMIT }}
|
||||||
# Storage (v0.12.0+)
|
# Storage (v0.12.0+)
|
||||||
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||||
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||||
@@ -727,8 +727,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Rollout and verify
|
- name: Rollout and verify
|
||||||
env:
|
env:
|
||||||
SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
|
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
||||||
ENV: ${{ steps.env.outputs.ENVIRONMENT }}
|
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
|
||||||
run: |
|
run: |
|
||||||
kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE}
|
kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE}
|
||||||
kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE}
|
kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE}
|
||||||
@@ -753,36 +753,46 @@ jobs:
|
|||||||
|
|
||||||
# ── Post-deploy: verify schema ─────────────
|
# ── Post-deploy: verify schema ─────────────
|
||||||
- name: Verify schema migration
|
- name: Verify schema migration
|
||||||
|
env:
|
||||||
|
EXPECTED_DB: ${{ steps.setup.outputs.DB_NAME }}
|
||||||
run: |
|
run: |
|
||||||
sleep 5
|
sleep 5
|
||||||
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
|
HOST="${{ steps.setup.outputs.DEPLOY_HOST }}"
|
||||||
BP="${{ steps.env.outputs.BASE_PATH }}"
|
BP="${{ steps.setup.outputs.BASE_PATH }}"
|
||||||
HEALTH=$(curl -sf "https://${HOST}${BP}/api/v1/health" 2>/dev/null || echo "unreachable")
|
HEALTH=$(curl -sf "https://${HOST}${BP}/health" 2>/dev/null || echo "unreachable")
|
||||||
echo "Health: ${HEALTH}"
|
echo "Health: ${HEALTH}"
|
||||||
|
|
||||||
SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true)
|
SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true)
|
||||||
DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || 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
|
if [[ -n "${SCHEMA}" ]] && [[ -n "${DB_OK}" ]]; then
|
||||||
echo "✅ Schema: ${SCHEMA} | DB: connected"
|
echo "✅ Schema: ${SCHEMA} | DB: ${ACTUAL_DB}"
|
||||||
else
|
else
|
||||||
echo "⚠ Could not verify schema (backend may still be starting)"
|
echo "⚠ Could not verify schema (backend may still be starting)"
|
||||||
echo " Response: ${HEALTH:0:200}"
|
echo " Response: ${HEALTH:0:200}"
|
||||||
fi
|
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
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
|
HOST="${{ steps.setup.outputs.DEPLOY_HOST }}"
|
||||||
BP="${{ steps.env.outputs.BASE_PATH }}"
|
BP="${{ steps.setup.outputs.BASE_PATH }}"
|
||||||
echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY
|
echo "## ✅ Deployed to ${{ steps.setup.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| | |" >> $GITHUB_STEP_SUMMARY
|
echo "| | |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **URL** | https://${HOST}${BP}/ |" >> $GITHUB_STEP_SUMMARY
|
echo "| **URL** | https://${HOST}${BP}/ |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Database** | \`${{ steps.setup.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| **DB Wipe** | ${{ steps.setup.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then
|
if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then
|
||||||
if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then
|
if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then
|
||||||
echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -792,6 +802,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
|
if [[ "${{ steps.setup.outputs.is_release }}" == "true" ]]; then
|
||||||
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
@@ -28,12 +28,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
DB_PORT: "5432"
|
POSTGRES_PORT: "5432"
|
||||||
DB_USER: switchboard
|
POSTGRES_USER: switchboard
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
|
||||||
DB_NAME: chat_switchboard
|
POSTGRES_DB: chat_switchboard
|
||||||
DB_SSL_MODE: disable
|
POSTGRES_SSLMODE: disable
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ spec:
|
|||||||
name: switchboard-db-credentials
|
name: switchboard-db-credentials
|
||||||
key: POSTGRES_PASSWORD
|
key: POSTGRES_PASSWORD
|
||||||
- name: DATABASE_URL
|
- 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
|
- name: SWITCHBOARD_ADMIN_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ func Load() *Config {
|
|||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
DatabaseURL: resolveDatabaseURL(),
|
||||||
DBDriver: getEnv("DB_DRIVER", ""),
|
DBDriver: getEnv("DB_DRIVER", ""),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
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 /.
|
// sanitizeBasePath ensures the path starts with / and doesn't end with /.
|
||||||
// Empty string means root (no prefix).
|
// Empty string means root (no prefix).
|
||||||
func sanitizeBasePath(p string) string {
|
func sanitizeBasePath(p string) string {
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ func connectPostgres(dsn string) error {
|
|||||||
return nil
|
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
|
var err error
|
||||||
DB, err = sql.Open("postgres", dsn)
|
DB, err = sql.Open("postgres", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,7 +61,7 @@ func connectPostgres(dsn string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CurrentDialect = DialectPostgres
|
CurrentDialect = DialectPostgres
|
||||||
log.Println("✅ Database connected (postgres)")
|
log.Printf("✅ Database connected (postgres, db=%s)", dbName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +108,7 @@ func connectSQLite(dsn string) error {
|
|||||||
// Verify WAL mode took effect (some in-memory modes ignore it).
|
// Verify WAL mode took effect (some in-memory modes ignore it).
|
||||||
var mode string
|
var mode string
|
||||||
DB.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
DB.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
||||||
|
dbName = dsn
|
||||||
log.Printf("✅ Database connected (sqlite, journal_mode=%s, path=%s)", mode, dsn)
|
log.Printf("✅ Database connected (sqlite, journal_mode=%s, path=%s)", mode, dsn)
|
||||||
|
|
||||||
CurrentDialect = DialectSQLite
|
CurrentDialect = DialectSQLite
|
||||||
@@ -125,6 +130,32 @@ func detectDriver(dsn string) string {
|
|||||||
return "postgres"
|
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.
|
// Close gracefully shuts down the connection pool.
|
||||||
func Close() {
|
func Close() {
|
||||||
if DB != nil {
|
if DB != nil {
|
||||||
@@ -139,3 +170,11 @@ func IsConnected() bool {
|
|||||||
}
|
}
|
||||||
return DB.Ping() == nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
16
server/database/migrations/010_v0211_workspace_bindings.sql
Normal file
16
server/database/migrations/010_v0211_workspace_bindings.sql
Normal file
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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) ──────────
|
// ── Multi-model @mention routing (v0.20.0) ──────────
|
||||||
// Check if the message @mentions specific channel models.
|
// Check if the message @mentions specific channel models.
|
||||||
// If multiple models are targeted, fan out sequentially.
|
// 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)
|
parsed := mentions.Parse(req.Content, roster)
|
||||||
targets := mentions.ResolvedModels(parsed)
|
targets := mentions.ResolvedModels(parsed)
|
||||||
if len(targets) > 1 {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +259,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
// Attach tool definitions if model supports tool calling and tools are available
|
// Attach tool definitions if model supports tool calling and tools are available
|
||||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
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
|
// Determine streaming
|
||||||
@@ -265,9 +269,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stream {
|
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 {
|
} 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,
|
c *gin.Context,
|
||||||
targets []models.ChannelModel,
|
targets []models.ChannelModel,
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
channelID, userID, personaID, presetSystemPrompt string,
|
channelID, userID, personaID, presetSystemPrompt, workspaceID string,
|
||||||
req completionRequest,
|
req completionRequest,
|
||||||
) {
|
) {
|
||||||
// Set SSE headers once for the entire multi-model stream
|
// 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)
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
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
|
// 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
|
// Persist assistant message with model attribution
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
@@ -390,13 +394,21 @@ func (h *CompletionHandler) multiModelStream(
|
|||||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||||
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
||||||
// Tools whose names appear in disabledTools are excluded.
|
// 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
|
// Build disabled set for O(1) lookup
|
||||||
disabled := make(map[string]bool, len(disabledTools))
|
disabled := make(map[string]bool, len(disabledTools))
|
||||||
for _, name := range disabledTools {
|
for _, name := range disabledTools {
|
||||||
disabled[name] = true
|
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)
|
allTools := tools.AllDefinitionsFiltered(disabled)
|
||||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||||
for _, t := range allTools {
|
for _, t := range allTools {
|
||||||
@@ -519,9 +531,9 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
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
|
// Persist assistant response
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
@@ -543,7 +555,7 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, userID, model, configID, providerScope, personaID string,
|
channelID, userID, model, configID, providerScope, personaID, workspaceID string,
|
||||||
) {
|
) {
|
||||||
var totalInput, totalOutput int
|
var totalInput, totalOutput int
|
||||||
var totalCacheCreation, totalCacheRead int
|
var totalCacheCreation, totalCacheRead int
|
||||||
@@ -607,9 +619,10 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
req.Messages = append(req.Messages, assistantMsg)
|
req.Messages = append(req.Messages, assistantMsg)
|
||||||
|
|
||||||
execCtx := tools.ExecutionContext{
|
execCtx := tools.ExecutionContext{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
PersonaID: personaID,
|
PersonaID: personaID,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
}
|
}
|
||||||
for _, tc := range resp.ToolCalls {
|
for _, tc := range resp.ToolCalls {
|
||||||
call := tools.ToolCall{
|
call := tools.ToolCall{
|
||||||
|
|||||||
@@ -508,14 +508,15 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach tool definitions (same as normal completion)
|
// Attach tool definitions (same as normal completion)
|
||||||
|
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
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) ──
|
// ── 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
|
// Persist as sibling (regen) with tool activity
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func streamWithToolLoop(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, userID, channelID, personaID string,
|
model, userID, channelID, personaID, workspaceID string,
|
||||||
hub *events.Hub,
|
hub *events.Hub,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
@@ -166,9 +166,10 @@ func streamWithToolLoop(
|
|||||||
|
|
||||||
// Execute all tool calls
|
// Execute all tool calls
|
||||||
execCtx := tools.ExecutionContext{
|
execCtx := tools.ExecutionContext{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
PersonaID: personaID,
|
PersonaID: personaID,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
}
|
}
|
||||||
for _, tc := range toolCalls {
|
for _, tc := range toolCalls {
|
||||||
call := tools.ToolCall{
|
call := tools.ToolCall{
|
||||||
@@ -251,7 +252,7 @@ func streamModelResponse(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, displayName, userID, channelID, personaID string,
|
model, displayName, userID, channelID, personaID, workspaceID string,
|
||||||
hub *events.Hub,
|
hub *events.Hub,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
flusher, _ := c.Writer.(http.Flusher)
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
@@ -354,9 +355,10 @@ func streamModelResponse(
|
|||||||
req.Messages = append(req.Messages, assistantMsg)
|
req.Messages = append(req.Messages, assistantMsg)
|
||||||
|
|
||||||
execCtx := tools.ExecutionContext{
|
execCtx := tools.ExecutionContext{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
PersonaID: personaID,
|
PersonaID: personaID,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
}
|
}
|
||||||
for _, tc := range toolCalls {
|
for _, tc := range toolCalls {
|
||||||
call := tools.ToolCall{
|
call := tools.ToolCall{
|
||||||
|
|||||||
@@ -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)
|
// Role resolver for model role dispatch (needs stores + vault + bus)
|
||||||
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
||||||
|
|
||||||
@@ -236,6 +241,7 @@ func main() {
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"database": database.IsConnected(),
|
"database": database.IsConnected(),
|
||||||
|
"database_name": database.Name(),
|
||||||
"schema_version": database.SchemaVersion(),
|
"schema_version": database.SchemaVersion(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -256,6 +262,7 @@ func main() {
|
|||||||
"version": Version,
|
"version": Version,
|
||||||
"schema_version": database.SchemaVersion(),
|
"schema_version": database.SchemaVersion(),
|
||||||
"database": database.IsConnected(),
|
"database": database.IsConnected(),
|
||||||
|
"database_name": database.Name(),
|
||||||
"providers": providers.List(),
|
"providers": providers.List(),
|
||||||
}
|
}
|
||||||
if database.IsConnected() {
|
if database.IsConnected() {
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ type Channel struct {
|
|||||||
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
ProjectID *string `json:"project_id,omitempty" db:"project_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"`
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +386,7 @@ type Project struct {
|
|||||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
IsArchived bool `json:"is_archived" db:"is_archived"`
|
||||||
|
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||||
|
|
||||||
// Computed fields (not DB columns)
|
// Computed fields (not DB columns)
|
||||||
@@ -400,6 +402,7 @@ type ProjectPatch struct {
|
|||||||
Color *string `json:"color,omitempty"`
|
Color *string `json:"color,omitempty"`
|
||||||
Icon *string `json:"icon,omitempty"`
|
Icon *string `json:"icon,omitempty"`
|
||||||
IsArchived *bool `json:"is_archived,omitempty"`
|
IsArchived *bool `json:"is_archived,omitempty"`
|
||||||
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||||
Settings JSONMap `json:"settings,omitempty"`
|
Settings JSONMap `json:"settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ type ChannelStore interface {
|
|||||||
|
|
||||||
// Ownership check
|
// Ownership check
|
||||||
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -266,3 +266,24 @@ func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (
|
|||||||
channelID, userID).Scan(&exists)
|
channelID, userID).Scan(&exists)
|
||||||
return exists, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) {
|
||||||
var p models.Project
|
var p models.Project
|
||||||
var teamID sql.NullString
|
var teamID, workspaceID sql.NullString
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
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,
|
p.created_at, p.updated_at,
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases 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
|
FROM projects p
|
||||||
WHERE p.id = $1`, id).Scan(
|
WHERE p.id = $1`, id).Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
&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.CreatedAt, &p.UpdatedAt,
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
||||||
)
|
)
|
||||||
@@ -49,6 +49,7 @@ func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
p.TeamID = NullableStringPtr(teamID)
|
||||||
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,9 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje
|
|||||||
if patch.IsArchived != nil {
|
if patch.IsArchived != nil {
|
||||||
b.Set("is_archived", *patch.IsArchived)
|
b.Set("is_archived", *patch.IsArchived)
|
||||||
}
|
}
|
||||||
|
if patch.WorkspaceID != nil {
|
||||||
|
b.Set("workspace_id", models.NullString(patch.WorkspaceID))
|
||||||
|
}
|
||||||
if len(patch.Settings) > 0 {
|
if len(patch.Settings) > 0 {
|
||||||
// Merge: read existing settings, overlay with patch values, write back.
|
// Merge: read existing settings, overlay with patch values, write back.
|
||||||
var existing models.JSONMap
|
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
|
// User sees: their personal projects + team projects for their teams + global projects
|
||||||
q := `
|
q := `
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
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,
|
p.created_at, p.updated_at,
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases 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
|
var result []models.Project
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Project
|
var p models.Project
|
||||||
var teamID sql.NullString
|
var teamID, workspaceID sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
&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.CreatedAt, &p.UpdatedAt,
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
p.TeamID = NullableStringPtr(teamID)
|
||||||
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
||||||
result = append(result, p)
|
result = append(result, p)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
|
|||||||
@@ -268,3 +268,24 @@ func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (
|
|||||||
channelID, userID).Scan(&exists)
|
channelID, userID).Scan(&exists)
|
||||||
return exists, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) {
|
||||||
var p models.Project
|
var p models.Project
|
||||||
var teamID sql.NullString
|
var teamID, workspaceID sql.NullString
|
||||||
var archived int
|
var archived int
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
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,
|
p.created_at, p.updated_at,
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases 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
|
FROM projects p
|
||||||
WHERE p.id = ?`, id).Scan(
|
WHERE p.id = ?`, id).Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
&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),
|
st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
&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.IsArchived = archived != 0
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
p.TeamID = NullableStringPtr(teamID)
|
||||||
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje
|
|||||||
if patch.IsArchived != nil {
|
if patch.IsArchived != nil {
|
||||||
add("is_archived", boolToInt(*patch.IsArchived))
|
add("is_archived", boolToInt(*patch.IsArchived))
|
||||||
}
|
}
|
||||||
|
if patch.WorkspaceID != nil {
|
||||||
|
add("workspace_id", models.NullString(patch.WorkspaceID))
|
||||||
|
}
|
||||||
if len(patch.Settings) > 0 {
|
if len(patch.Settings) > 0 {
|
||||||
// Merge: read existing settings, overlay with patch values, write back.
|
// Merge: read existing settings, overlay with patch values, write back.
|
||||||
var existing models.JSONMap
|
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) {
|
func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
|
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,
|
p.created_at, p.updated_at,
|
||||||
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
||||||
(SELECT COUNT(*) FROM project_knowledge_bases 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
|
var result []models.Project
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Project
|
var p models.Project
|
||||||
var teamID sql.NullString
|
var teamID, workspaceID sql.NullString
|
||||||
var archived int
|
var archived int
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
&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),
|
st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||||
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -445,6 +449,7 @@ func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...inte
|
|||||||
}
|
}
|
||||||
p.IsArchived = archived != 0
|
p.IsArchived = archived != 0
|
||||||
p.TeamID = NullableStringPtr(teamID)
|
p.TeamID = NullableStringPtr(teamID)
|
||||||
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
||||||
result = append(result, p)
|
result = append(result, p)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ type ToolResult struct {
|
|||||||
|
|
||||||
// ExecutionContext provides tool implementations with the info they need.
|
// ExecutionContext provides tool implementations with the info they need.
|
||||||
type ExecutionContext struct {
|
type ExecutionContext struct {
|
||||||
UserID string
|
UserID string
|
||||||
ChannelID string
|
ChannelID string
|
||||||
PersonaID string // set when a persona is active — tools use for scoped KB access
|
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.
|
// Tool is the interface every built-in tool implements.
|
||||||
|
|||||||
497
server/tools/workspace.go
Normal file
497
server/tools/workspace.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
// Stat returns file metadata without reading content.
|
||||||
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
|
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
|
||||||
relPath = cleanPath(relPath)
|
relPath = cleanPath(relPath)
|
||||||
|
|||||||
Reference in New Issue
Block a user