Changeset 0.21.1.1 (#87)

This commit is contained in:
2026-03-01 16:40:26 +00:00
parent 70aa78e486
commit 09c7281552
20 changed files with 853 additions and 102 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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
}

View 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;

View File

@@ -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);

View File

@@ -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{

View File

@@ -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 != "" {

View File

@@ -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{

View File

@@ -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() {

View File

@@ -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"`
}

View File

@@ -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)
}
// =========================================

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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.

497
server/tools/workspace.go Normal file
View 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
}

View File

@@ -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)