This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/sqlite/workspace.go
2026-03-01 17:45:50 +00:00

425 lines
12 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type WorkspaceStore struct{}
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
// ── columns ────────────────────────────────
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, created_at, updated_at`
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at`
// ── scanners ───────────────────────────────
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
var w models.Workspace
var maxBytes sql.NullInt64
err := row.Scan(
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
&w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled,
st(&w.CreatedAt), st(&w.UpdatedAt),
)
if err != nil {
return nil, err
}
if maxBytes.Valid {
w.MaxBytes = &maxBytes.Int64
}
return &w, nil
}
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
var f models.WorkspaceFile
var contentType sql.NullString
var sha256 sql.NullString
var indexStatus sql.NullString
err := row.Scan(
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
&contentType, &f.SizeBytes, &sha256,
&indexStatus, &f.ChunkCount,
st(&f.CreatedAt), st(&f.UpdatedAt),
)
if err != nil {
return nil, err
}
if contentType.Valid {
f.ContentType = contentType.String
}
if sha256.Valid {
f.SHA256 = sha256.String
}
if indexStatus.Valid {
f.IndexStatus = indexStatus.String
}
return &f, nil
}
// ── Workspace CRUD ─────────────────────────
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
if w.ID == "" {
w.ID = store.NewID()
}
now := time.Now().UTC()
w.CreatedAt = now
w.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
w.MaxBytes, w.Status,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
row := DB.QueryRowContext(ctx,
`SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id)
return scanWorkspace(row)
}
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
b := NewUpdate("workspaces")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.MaxBytes != nil {
b.Set("max_bytes", *patch.MaxBytes)
}
if patch.Status != nil {
b.Set("status", *patch.Status)
}
if patch.IndexingEnabled != nil {
b.Set("indexing_enabled", *patch.IndexingEnabled)
}
if !b.HasSets() {
return nil
}
b.SetExpr("updated_at", nowExpr)
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
return err
}
// ── Ownership lookup ───────────────────────
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
row := DB.QueryRowContext(ctx,
`SELECT `+workspaceCols+` FROM workspaces
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
ORDER BY created_at DESC LIMIT 1`,
ownerType, ownerID)
return scanWorkspace(row)
}
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+workspaceCols+` FROM workspaces
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
ORDER BY created_at DESC`,
ownerType, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Workspace
for rows.Next() {
w, err := scanWorkspace(rows)
if err != nil {
return nil, err
}
out = append(out, *w)
}
return out, rows.Err()
}
// ── File index ─────────────────────────────
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
if f.ID == "" {
f.ID = store.NewID()
}
now := time.Now().UTC()
f.CreatedAt = now
f.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (workspace_id, path) DO UPDATE SET
is_directory = excluded.is_directory,
content_type = excluded.content_type,
size_bytes = excluded.size_bytes,
sha256 = excluded.sha256,
updated_at = datetime('now')`,
f.ID, f.WorkspaceID, f.Path, f.IsDirectory,
models.NullString(&f.ContentType), f.SizeBytes,
models.NullString(&f.SHA256),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`,
workspaceID, path)
return err
}
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`,
workspaceID, prefix+"%")
return err
}
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
row := DB.QueryRowContext(ctx,
`SELECT `+workspaceFileCols+` FROM workspace_files
WHERE workspace_id = ? AND path = ?`,
workspaceID, path)
return scanWorkspaceFile(row)
}
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
var query string
var args []interface{}
if recursive {
if prefix == "" {
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = ? ORDER BY path`
args = []interface{}{workspaceID}
} else {
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = ? AND path LIKE ? ORDER BY path`
args = []interface{}{workspaceID, prefix + "%"}
}
} else {
if prefix == "" {
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path`
args = []interface{}{workspaceID}
} else {
// Direct children: starts with prefix, no slash after prefix
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
WHERE workspace_id = ? AND path LIKE ?
AND substr(path, %d) NOT LIKE '%%/%%'
ORDER BY path`, len(prefix)+1)
args = []interface{}{workspaceID, prefix + "%"}
}
}
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.WorkspaceFile
for rows.Next() {
f, err := scanWorkspaceFile(rows)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, rows.Err()
}
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID)
return err
}
// ── Stats ──────────────────────────────────
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
err := DB.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
COALESCE(SUM(size_bytes), 0)
FROM workspace_files WHERE workspace_id = ?`,
workspaceID,
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
if err != nil {
return nil, err
}
var maxBytes sql.NullInt64
err = DB.QueryRowContext(ctx,
`SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID,
).Scan(&maxBytes)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if maxBytes.Valid {
stats.MaxBytes = &maxBytes.Int64
}
return stats, nil
}
// ── Chunks (v0.21.2) ─────────────────────────
func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error {
if len(chunks) == 0 {
return nil
}
valueParts := make([]string, 0, len(chunks))
args := make([]interface{}, 0, len(chunks)*8)
for _, c := range chunks {
c.ID = store.NewID()
valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)")
var embJSON interface{}
if len(c.Embedding) > 0 {
embJSON = ToJSON(c.Embedding)
}
args = append(args, c.ID, c.WorkspaceID, c.FileID, c.ChunkIndex,
c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata))
}
q := fmt.Sprintf(`INSERT INTO workspace_chunks
(id, workspace_id, file_id, chunk_index, content, token_count, embedding, metadata)
VALUES %s`, strings.Join(valueParts, ", "))
_, err := DB.ExecContext(ctx, q, args...)
return err
}
func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = ?`, fileID)
return err
}
// SimilaritySearch performs app-level cosine similarity for SQLite.
// Loads all chunks for the workspace, computes cosine distance in Go,
// and returns the top results above threshold.
func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) {
if len(queryVec) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 10
}
rows, err := DB.QueryContext(ctx, `
SELECT f.path, c.content, c.embedding, c.metadata
FROM workspace_chunks c
JOIN workspace_files f ON c.file_id = f.id
WHERE c.workspace_id = ?
AND c.embedding IS NOT NULL`,
workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
type candidate struct {
result models.WorkspaceChunkResult
similarity float64
}
var candidates []candidate
for rows.Next() {
var filePath, content, embJSON string
var metaJSON sql.NullString
if err := rows.Scan(&filePath, &content, &embJSON, &metaJSON); err != nil {
return nil, err
}
var embedding []float64
if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil {
continue // skip malformed
}
sim := wsCosineSimilarity(queryVec, embedding)
if sim > threshold {
r := models.WorkspaceChunkResult{
FilePath: filePath,
Content: content,
Score: sim,
}
if metaJSON.Valid {
ScanJSON(metaJSON.String, &r.Metadata)
}
if r.Metadata != nil {
if lh, ok := r.Metadata["line_start"]; ok {
if n, ok := lh.(float64); ok {
r.LineHint = int(n)
}
}
}
candidates = append(candidates, candidate{result: r, similarity: sim})
}
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].similarity > candidates[j].similarity
})
if len(candidates) > limit {
candidates = candidates[:limit]
}
results := make([]models.WorkspaceChunkResult, len(candidates))
for i, c := range candidates {
results[i] = c.result
}
return results, nil
}
func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error {
_, err := DB.ExecContext(ctx,
`UPDATE workspace_files SET index_status = ?, chunk_count = ?, updated_at = datetime('now') WHERE id = ?`,
status, chunkCount, fileID)
return err
}
// wsCosineSimilarity computes cosine similarity between two vectors.
// Named to avoid collision with the KB package-level function.
func wsCosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}