Changeset 0.21.1 (#86)

This commit is contained in:
2026-03-01 14:44:55 +00:00
parent 817062e5fc
commit 70aa78e486
18 changed files with 3778 additions and 29 deletions

View File

@@ -2,6 +2,18 @@
All notable changes to Chat Switchboard. All notable changes to Chat Switchboard.
## [0.21.0] — 2026-03-01
### Added
- **Workspace Storage Primitive.** Platform-level file storage bound to users, projects, channels, or teams via polymorphic owner model. Dual-layer architecture: PVC filesystem (source of truth) with DB metadata index (queryable cache). Workspaces support configurable quotas, status lifecycle (active/archived/deleting), and owner-based authorization inheritance.
- **Workspace data model.** Two new tables: `workspaces` (polymorphic owner_type/owner_id, root_path, max_bytes quota, status) and `workspace_files` (path, MIME type, size, sha256, is_directory). Unique index on (workspace_id, path) enables upsert-on-conflict for file metadata sync. Migrations for both Postgres and SQLite.
- **`workspace.FS` package.** Filesystem operations layer with security-first design: atomic writes (temp file + rename with SHA256 computed via tee reader), path traversal guards (cleanPath normalization + absPath containment validation), symlink rejection on write targets. Operations: ReadFile, WriteFile, DeleteFile (with recursive guard), Mkdir, Stat, ListDir, Tree, Reconcile (filesystem→DB drift sync).
- **Archive operations.** Extract zip and tar.gz archives into workspaces with bomb protection: 10K file limit, 100MB single file cap, workspace quota enforcement during extraction. Common-prefix stripping handles GitHub-style `project-name/` wrapper directories. CreateArchive packages workspaces into downloadable zip or tar.gz.
- **Content type detection.** Extension-based MIME detection covering 40+ source code types (Go, Rust, Python, TypeScript, etc.) with `http.DetectContentType` sniffing fallback for unknown extensions.
- **`WorkspaceStore` interface.** Full CRUD for workspaces, file index operations (upsert, delete, delete-by-prefix, get, list with recursive/non-recursive modes), ownership lookup (GetByOwner, ListByOwner), and aggregate stats. Postgres and SQLite implementations.
- **Workspace API.** 15 new endpoints under `/api/v1/workspaces`: workspace CRUD (create, get, update, delete), file operations (list, read, write, delete, mkdir), archive management (upload with extraction, download), reconcile (FS→DB sync), stats. Owner-based authorization: user workspaces require self, channel workspaces require channel owner, project workspaces require project member, team workspaces require team member.
- **Unit tests.** Path cleaning (13 cases including traversal, dotfiles, whitespace), absPath traversal detection, MIME detection (14 extensions), unsafe path filtering (7 cases), common prefix detection (5 cases), write/read round-trip with mock store, delete verification, mkdir with index sync.
## [0.20.0] — 2026-03-01 ## [0.20.0] — 2026-03-01
### Added ### Added

View File

@@ -60,7 +60,7 @@ RUN sh /build/scripts/build-editor.sh /build/dist
# ── Stage 4: Production ───────────────────── # ── Stage 4: Production ─────────────────────
FROM nginx:1-alpine FROM nginx:1-alpine
RUN apk add --no-cache bash RUN apk add --no-cache bash git
# Go backend binary # Go backend binary
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard COPY --from=backend /bin/switchboard /usr/local/bin/switchboard

View File

@@ -1 +1 @@
0.20.0 0.21.0

976
docs/DESIGN-0.21.0.md Normal file
View File

@@ -0,0 +1,976 @@
# Design — v0.21.x: Workspaces + Extension Surfaces
**Status:** Planning
**Depends on:** v0.20.0 (notifications), v0.17.2 (CM6), v0.11.0 (extension foundation)
**Feeds into:** v0.22.0 (smart routing), v0.23.0 (multi-participant channels)
---
## Guiding Principle
The **workspace** is a platform primitive — not an editor-mode concept. Even in
pure chat mode, uploading a zip, having the AI operate on real files at real
paths, and downloading the result as an archive is a first-class workflow. Every
surface (chat, editor, article, future modes) benefits from workspace access.
This changes the layering: workspace infrastructure lands first as foundational
storage, then surfaces consume it.
---
## Release Decomposition
```
v0.21.0 Workspace Storage (platform primitive)
v0.21.1 Workspace Tools + Channel/Project Binding
v0.21.2 Workspace Indexing + Semantic Search
v0.21.3 Surface Infrastructure + REPL
v0.21.4 Git Integration
v0.21.5 Editor Surface (Development Mode)
v0.21.6 Article Surface + Document Output Pipeline
```
---
## v0.21.0 — Workspace Storage
Pure backend. No tools, no UI, no surfaces. Just the storage primitive with
full CRUD API and archive support.
### Data Model
**`workspaces` table**
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | PK |
| `owner_type` | TEXT | `user`, `project`, `channel`, `team` |
| `owner_id` | UUID | FK polymorphic |
| `name` | TEXT | display name |
| `root_path` | TEXT | PVC-relative path: `workspaces/{id}` |
| `max_bytes` | BIGINT | quota (NULL = system default) |
| `status` | TEXT | `active`, `archived`, `deleting` |
| `created_at` | TIMESTAMPTZ | |
| `updated_at` | TIMESTAMPTZ | |
Index: `(owner_type, owner_id)` — lookup workspace(s) for a given owner.
**`workspace_files` table** (metadata index — filesystem is source of truth)
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | PK |
| `workspace_id` | UUID | FK → workspaces |
| `path` | TEXT | relative path from workspace root: `src/main.go` |
| `is_directory` | BOOLEAN | |
| `content_type` | TEXT | MIME type (detected or inferred) |
| `size_bytes` | BIGINT | 0 for dirs |
| `sha256` | TEXT | content hash (NULL for dirs) |
| `created_at` | TIMESTAMPTZ | |
| `updated_at` | TIMESTAMPTZ | |
Index: `UNIQUE (workspace_id, path)` — one entry per path.
Index: `(workspace_id, content_type)` — filter by type.
Design note: the DB index is a metadata cache. The PVC filesystem is the source
of truth. Syncing happens on write operations and optionally via a reconcile
endpoint. This avoids the complexity of fsnotify while keeping queries fast.
### Filesystem Layout (PVC)
```
/data/storage/
attachments/ # existing (v0.12.0)
processing/ # existing extraction queue
workspaces/ # NEW
{workspace_id}/
.workspace.json # metadata: owner, quotas, created_at
files/ # actual file tree root
src/
main.go
README.md
```
Why a separate `files/` subdirectory: keeps workspace metadata (`.workspace.json`,
future `.git/` in v0.21.4) out of the user's file namespace.
### Store Interface
```go
type WorkspaceStore interface {
// CRUD
Create(ctx context.Context, w *models.Workspace) error
GetByID(ctx context.Context, id string) (*models.Workspace, error)
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
Delete(ctx context.Context, id string) error
// Ownership lookup
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
// File index
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
DeleteFile(ctx context.Context, workspaceID, path string) error
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
// Stats
GetDiskUsage(ctx context.Context, workspaceID string) (int64, error)
}
```
### Workspace FS (Filesystem Operations)
Separate from the store — this operates on PVC, not DB.
```go
type WorkspaceFS struct {
basePath string // e.g. /data/storage/workspaces
store WorkspaceStore // for metadata sync
}
// File CRUD — all paths relative to workspace root
func (fs *WorkspaceFS) ReadFile(ctx context.Context, w *models.Workspace, path string) (io.ReadCloser, int64, error)
func (fs *WorkspaceFS) WriteFile(ctx context.Context, w *models.Workspace, path string, r io.Reader, size int64) error
func (fs *WorkspaceFS) DeleteFile(ctx context.Context, w *models.Workspace, path string) error
func (fs *WorkspaceFS) Mkdir(ctx context.Context, w *models.Workspace, path string) error
func (fs *WorkspaceFS) Stat(ctx context.Context, w *models.Workspace, path string) (*models.WorkspaceFile, error)
func (fs *WorkspaceFS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error)
// Tree: returns full file listing with sizes (for file tree UI)
func (fs *WorkspaceFS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error)
// Archive operations
func (fs *WorkspaceFS) ExtractArchive(ctx context.Context, w *models.Workspace, r io.Reader, format string) (int, error)
func (fs *WorkspaceFS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (io.ReadCloser, error)
// Maintenance
func (fs *WorkspaceFS) Reconcile(ctx context.Context, w *models.Workspace) error // sync FS → DB index
func (fs *WorkspaceFS) Destroy(ctx context.Context, w *models.Workspace) error // rm -rf + DB cleanup
```
WriteFile updates the DB index (upsert workspace_files) after successful write.
DeleteFile removes the DB row. Reconcile walks the FS and patches any drift.
### API Endpoints
```
POST /api/v1/workspaces # create workspace
GET /api/v1/workspaces/:id # get metadata
PATCH /api/v1/workspaces/:id # update name/quota
DELETE /api/v1/workspaces/:id # delete (async cleanup)
GET /api/v1/workspaces/:id/files?path=&recursive= # list files
GET /api/v1/workspaces/:id/files/read?path= # read file content
PUT /api/v1/workspaces/:id/files/write?path= # write file (body = content)
DELETE /api/v1/workspaces/:id/files?path= # delete file/dir
POST /api/v1/workspaces/:id/files/mkdir?path= # create directory
POST /api/v1/workspaces/:id/archive/upload # upload + extract zip/tar.gz
GET /api/v1/workspaces/:id/archive/download?format= # download as zip/tar.gz
POST /api/v1/workspaces/:id/reconcile # force FS → DB sync
GET /api/v1/workspaces/:id/stats # disk usage, file count
```
### Security
- Workspace access inherits from owner: if you can access the project/channel,
you can access its workspace.
- Path traversal guard: all paths are cleaned and must resolve within the
workspace root. Symlinks are rejected.
- File size limit: configurable per-workspace `max_bytes` with system default
(`WORKSPACE_MAX_BYTES`, default 500MB).
- Archive extraction bomb protection: max files (10,000), max total extracted
size (workspace quota), max single file (100MB).
### Migrations
- `xxx_workspace_tables.sql` (Postgres + SQLite): `workspaces`, `workspace_files`
### Checklist
- [ ] `models.Workspace`, `models.WorkspaceFile`, `models.WorkspacePatch` structs
- [ ] `WorkspaceStore` interface in `store/interfaces.go`
- [ ] Postgres implementation: `store/postgres/workspace.go`
- [ ] SQLite implementation: `store/sqlite/workspace.go`
- [ ] `WorkspaceFS` in new `server/workspace/` package
- [ ] Path traversal validation (must not escape workspace root)
- [ ] Archive extract (zip, tar.gz) with bomb protection
- [ ] Archive create (zip, tar.gz)
- [ ] MIME type detection on write (`net/http.DetectContentType` + extension fallback)
- [ ] Workspace CRUD handlers (6 endpoints)
- [ ] File CRUD handlers (5 endpoints)
- [ ] Archive handlers (2 endpoints)
- [ ] Reconcile + stats handlers (2 endpoints)
- [ ] `Stores.Workspaces` wired in `store/postgres/stores.go` + `store/sqlite/stores.go`
- [ ] Integration tests: workspace CRUD, file CRUD, archive round-trip, path traversal rejection
- [ ] PVC directory creation on startup (`/data/storage/workspaces/`)
---
## v0.21.1 — Workspace Tools + Channel/Project Binding
Make workspaces useful in chat mode. AI can operate on files through tool calls.
Channels and projects get workspace bindings.
### Tools (category: `workspace`)
**`workspace_ls`** — list files in workspace
```json
{
"path": "src/",
"recursive": false
}
```
Returns: array of `{ path, is_directory, content_type, size_bytes }`.
**`workspace_read`** — read a file
```json
{
"path": "src/main.go"
}
```
Returns: file content as text (with truncation guard for large files, e.g. 50KB
soft limit with "file truncated" indicator). Binary files return a base64
summary or "binary file, N bytes" message.
**`workspace_write`** — create or overwrite a file
```json
{
"path": "src/main.go",
"content": "package main\n\nfunc main() {\n}\n"
}
```
Returns: confirmation with path and size. Creates parent directories
automatically.
**`workspace_rm`** — delete a file or directory
```json
{
"path": "src/old_module/",
"recursive": false
}
```
Returns: confirmation. Recursive required for non-empty directories.
**`workspace_mv`** — rename or move a file
```json
{
"source": "src/old.go",
"destination": "src/new.go"
}
```
**`workspace_patch`** — apply a text diff/patch to a file
```json
{
"path": "src/main.go",
"operations": [
{ "find": "oldFunction()", "replace": "newFunction()" },
{ "find": "// TODO: remove", "replace": "" }
]
}
```
The `find`/`replace` approach (same pattern as `str_replace` in many AI
coding tools) is more reliable for LLMs than unified diffs. Each operation's
`find` string must match exactly once in the file.
### Channel Workspace Binding
**Schema change:** `channels` table gains optional `workspace_id` FK.
**Behavior:**
- When a workspace is bound to a channel, workspace tools are injected into
the tool set for that channel's completions (same pattern as KB tools via
persona binding).
- New channel setting: "Workspace" toggle in channel settings.
- Creating a workspace from channel settings auto-binds it.
**Archive upload flow (chat mode):**
1. User uploads a zip/tar.gz to a channel with a workspace.
2. Upload handler detects archive MIME type + workspace binding.
3. Extracts into workspace (not attachment store).
4. Workspace tools become available for AI to operate on files.
5. User or AI can call archive download to get results.
Non-archive files still go to attachment store as before (backward compatible).
### Project Workspace Binding
**Schema change:** `projects` table gains optional `workspace_id` FK.
**Behavior:**
- Project detail panel gets a "Files" tab showing workspace file tree.
- All channels in a project inherit the project workspace (unless the channel
has its own).
- Workspace resolution: channel workspace > project workspace (same override
pattern as persona/KB resolution).
### Frontend
- Channel settings: workspace toggle, create/bind workspace.
- Project detail panel: Files tab with tree view (reusable component for
editor surface later).
- Chat bar: workspace indicator when active (similar to KB indicator).
### Checklist
- [ ] `workspace_ls` tool implementation
- [ ] `workspace_read` tool with text truncation guard + binary detection
- [ ] `workspace_write` tool with auto-mkdir
- [ ] `workspace_rm` tool with recursive guard
- [ ] `workspace_mv` tool
- [ ] `workspace_patch` tool with find/replace operations
- [ ] Tool registration in `workspace` category
- [ ] Tool injection in completion handler when workspace bound
- [ ] `channels.workspace_id` column (migration, both dialects)
- [ ] `projects.workspace_id` column (migration, both dialects)
- [ ] Archive-to-workspace upload handler (detect archive + workspace → extract)
- [ ] Workspace resolution: channel → project fallback
- [ ] Channel settings UI: workspace toggle
- [ ] Project detail panel: Files tab
- [ ] Chat bar: workspace indicator
- [ ] Integration tests: tool execution, workspace resolution, archive upload flow
- [ ] Update `ExecutionContext` with workspace ID
---
## v0.21.2 — Workspace Indexing + Semantic Search
Embed text files in workspace for semantic search. Reuses the existing
`knowledge/` chunker and embedder — they're already decoupled from KB-specific
concerns.
### Data Model
**`workspace_chunks` table**
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | PK |
| `workspace_id` | UUID | FK → workspaces |
| `file_id` | UUID | FK → workspace_files |
| `chunk_index` | INT | ordinal within file |
| `content` | TEXT | chunk text |
| `token_count` | INT | estimated |
| `embedding` | vector(3072) / JSON | pgvector or JSON text (SQLite) |
| `metadata` | JSONB / JSON | extensible: line_start, heading, etc. |
Index: pgvector IVFFlat on `embedding` (Postgres), app-level cosine (SQLite).
Index: `(file_id)` — re-index on file change.
### Indexing Pipeline
```
file write/upload
→ is text file? (same textMIMETypes + code extensions)
→ extract content (already in memory from write, or read from FS)
→ chunk (reuse knowledge.SplitText with configurable ChunkConfig)
→ embed (reuse knowledge.Embedder)
→ store in workspace_chunks (DELETE old chunks for file_id, INSERT new)
→ update workspace_files.sha256 (content-addressed skip on no-change)
```
**Code-aware MIME types** (extend `textMIMETypes`):
The existing KB ingester recognizes `text/plain`, `text/markdown`, `text/csv`,
`text/html`. For workspace indexing, we also recognize source code by extension:
```go
var indexableExtensions = map[string]bool{
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true,
".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true,
".hpp": true, ".java": true, ".rb": true, ".php": true, ".swift": true,
".kt": true, ".scala": true, ".sh": true, ".bash": true, ".zsh": true,
".sql": true, ".yaml": true, ".yml": true, ".toml": true, ".json": true,
".xml": true, ".css": true, ".scss": true, ".less": true,
".md": true, ".txt": true, ".csv": true, ".html": true, ".htm": true,
".dockerfile": true, ".tf": true, ".hcl": true, ".proto": true,
".graphql": true, ".vue": true, ".svelte": true,
".pl": true, ".pm": true, // Perl
".lua": true, ".zig": true, ".nim": true, ".ex": true, ".exs": true,
}
```
**Content-addressed skip:** if `workspace_files.sha256` matches the new write,
skip re-chunking and re-embedding. This makes bulk operations (archive extract)
efficient — only changed files get re-indexed.
**Background indexing:** file writes trigger async indexing (same goroutine
pattern as KB ingestion with semaphore). The write API returns immediately; the
`workspace_files` row gets an `index_status` column: `pending`, `indexing`,
`ready`, `error`, `skipped` (binary/non-text).
### Code-Aware Chunking
For source code, the default chunker works reasonably well but we add a
code-specific separator hierarchy:
```go
func CodeChunkConfig() ChunkConfig {
return ChunkConfig{
ChunkSize: 1500, // larger chunks for code (functions can be long)
ChunkOverlap: 200,
Separators: []string{"\n\nfunc ", "\n\nclass ", "\n\ndef ", "\n\n", "\n", " "},
}
}
```
This is a simple heuristic — it splits on function/class boundaries when
possible, falling back to paragraph and line boundaries. It doesn't require
an AST parser and handles most languages well enough for search.
### Search Tool
**`workspace_search`** — semantic search across workspace files
```json
{
"query": "database connection pooling",
"top_k": 10,
"file_pattern": "*.go"
}
```
Returns: array of `{ file_path, chunk_content, score, line_hint }`.
The `file_pattern` is optional glob filtering (applied post-search on the
`workspace_files.path` join). `line_hint` is approximate line number from
chunk metadata.
### Workspace Store Extensions
```go
// Added to WorkspaceStore interface
InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error
DeleteChunksByFile(ctx context.Context, fileID string) error
SimilaritySearch(ctx context.Context, workspaceID string, embedding []float64, topK int) ([]models.WorkspaceChunkResult, error)
UpdateFileIndexStatus(ctx context.Context, fileID, status string) error
```
### `workspace_files` Schema Addition
| Column | Type | Notes |
|--------|------|-------|
| `index_status` | TEXT | `pending`, `indexing`, `ready`, `error`, `skipped` |
| `chunk_count` | INT | number of chunks after indexing |
### Indexing Configuration
- **Per-workspace toggle:** `workspaces.indexing_enabled` (default: `true`
when embedding role is configured, `false` otherwise).
- **System setting:** `WORKSPACE_INDEXING_ENABLED` env var (global kill switch).
- **Concurrency:** shared semaphore with KB ingestion (don't overwhelm the
embedding provider).
### Checklist
- [ ] `workspace_chunks` table (Postgres + SQLite migrations)
- [ ] `workspace_files.index_status` and `chunk_count` columns
- [ ] `WorkspaceChunk`, `WorkspaceChunkResult` model structs
- [ ] Store methods: `InsertChunks`, `DeleteChunksByFile`, `SimilaritySearch`, `UpdateFileIndexStatus`
- [ ] Postgres implementation with pgvector
- [ ] SQLite implementation with app-level cosine
- [ ] `workspace.Indexer` — reuses `knowledge.SplitText` + `knowledge.Embedder`
- [ ] Indexable type detection: MIME type + extension map
- [ ] Code-aware chunk config
- [ ] Content-addressed skip (sha256 check)
- [ ] Background indexing goroutine with semaphore
- [ ] `workspace_search` tool
- [ ] `workspace_search` glob filtering on file path
- [ ] Hook indexer into `WorkspaceFS.WriteFile` and `ExtractArchive`
- [ ] Workspace index status endpoint: `GET /api/v1/workspaces/:id/index-status`
- [ ] Integration tests: index pipeline, semantic search, content-addressed skip
- [ ] Notification on index complete/error (reuse notification service)
---
## v0.21.3 — Surface Infrastructure + REPL
Pure UI architecture. No workspace dependency. Can develop in parallel with
v0.21.0v0.21.2 on the frontend side.
### Surface Registration API
Extends the existing `ctx.*` extension API from v0.11.0.
```js
Extensions.register({
id: 'my-mode',
init(ctx) {
ctx.surfaces.register('my-surface', {
label: 'My Mode',
icon: 'code', // lucide icon name
regions: ['surface-main', 'sidebar-content'],
activate() { /* take over regions */ },
deactivate() { /* restore regions */ }
});
}
});
```
### Region Management
```js
ctx.ui.replace(regionId, element) // saves current content, swaps in element
ctx.ui.restore(regionId) // restores saved content
```
Regions are DOM containers identified by `data-surface-region` attributes.
`replace()` detaches the current children (preserved in memory, not destroyed),
inserts the new element. `restore()` re-attaches the saved children.
This is critical for CM6 — editor instances preserve state across mode switches
because the DOM nodes aren't destroyed, just detached.
### Surface Regions
```
┌──────────────────────────────────────────────────┐
│ [surface-header] │
├──────────────┬───────────────────────────────────┤
│ │ │
│ [sidebar- │ [surface-main] │
│ content] │ (chat messages / editor / │
│ │ article / custom) │
│ │ │
│ ├───────────────────────────────────┤
│ │ [surface-footer] │
│ │ (chat input / editor toolbar) │
└──────────────┴───────────────────────────────────┘
```
The sidebar-nav region (mode selector) is managed by the surface system itself,
not by individual extensions.
### Mode Selector
Appears in the sidebar below the brand logo, above chat history, when ≥1
extension surface is registered. Shows icon buttons for each mode. Chat is
always the first (default) mode.
```
[💬] [</>] [📄] ← mode selector (chat, editor, article)
──────────────
Chat History... ← sidebar-content (replaced per mode)
```
### Events
```js
Events.on('surface.activated', ({ surface, previous }) => { ... });
Events.on('surface.deactivated', ({ surface }) => { ... });
```
### REPL / Debug Console (#70)
Fourth tab in debug modal (Ctrl+Shift+L). See roadmap for full spec:
- `AsyncFunction` wrapper for top-level `await`
- Injected globals: `API`, `Events`, `Extensions`, `DebugLog`, `Surfaces`
- Pretty-print results (collapsible JSON, red errors with stack)
- Command history: ↑/↓ with `sessionStorage`
- Tab-completion on live object graphs
- Event label hints on `Events.publish('`/`Events.on('`
- Multi-line: Shift+Enter for newline, Enter to run
- Admin-gated OR `?debug=1` URL param
### Checklist
- [ ] `data-surface-region` attributes on index.html containers
- [ ] `surfaces.js` — SurfaceRegistry: register, activate, deactivate, getCurrent
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` with DOM preservation
- [ ] `ctx.surfaces.register()` API for extensions
- [ ] Mode selector component in sidebar
- [ ] Chat as default surface (implicit, always registered)
- [ ] `surface.activated` / `surface.deactivated` EventBus events
- [ ] REPL tab: AsyncFunction wrapper, global injection
- [ ] REPL tab: pretty-print results
- [ ] REPL tab: command history (sessionStorage)
- [ ] REPL tab: tab-completion on object graphs
- [ ] REPL tab: event label hints
- [ ] REPL tab: admin gate
- [ ] REPL tab: toolbar (clear, copy, help)
- [ ] Mobile: mode selector collapses to hamburger/bottom nav
- [ ] Integration with extension loader (surfaces from manifest)
- [ ] Documentation in EXTENSIONS.md §6 update
---
## v0.21.4 — Git Integration
Layer git operations onto workspace. A workspace can optionally track a git
remote. Credentials go through the vault.
### Schema Changes
**`workspaces` table additions:**
| Column | Type | Notes |
|--------|------|-------|
| `git_remote_url` | TEXT | nullable — HTTPS or SSH URL |
| `git_branch` | TEXT | nullable — default branch after clone |
| `git_credential_id` | UUID | nullable — FK to git_credentials |
| `git_last_sync` | TIMESTAMPTZ | nullable — last pull/push |
**`git_credentials` table:**
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | PK |
| `user_id` | UUID | FK → users (owner) |
| `name` | TEXT | display name: "GitHub PAT", "GitLab token" |
| `auth_type` | TEXT | `https_pat`, `https_basic`, `ssh_key` |
| `encrypted_data` | BYTEA | AES-256-GCM (same vault pattern as BYOK) |
| `created_at` | TIMESTAMPTZ | |
Encrypted data contains: `{ "token": "..." }` for PAT, `{ "username": "...",
"password": "..." }` for basic, `{ "private_key": "...", "passphrase": "..." }`
for SSH.
### Git Operations (server-side)
Uses `os/exec` calling `git` binary (not a Go git library — keeps binary size
down and avoids CGO). The git binary is already in the Docker image for other
purposes.
```go
type GitOps struct {
fs *WorkspaceFS
credentials CredentialResolver // vault decryption
}
func (g *GitOps) Clone(ctx context.Context, w *models.Workspace, url, branch string, credID string) error
func (g *GitOps) Pull(ctx context.Context, w *models.Workspace) error
func (g *GitOps) Push(ctx context.Context, w *models.Workspace, message string) error
func (g *GitOps) Status(ctx context.Context, w *models.Workspace) (*GitStatus, error)
func (g *GitOps) Diff(ctx context.Context, w *models.Workspace, path string) (string, error)
func (g *GitOps) Commit(ctx context.Context, w *models.Workspace, message string, paths []string) error
func (g *GitOps) Log(ctx context.Context, w *models.Workspace, n int) ([]GitLogEntry, error)
func (g *GitOps) BranchList(ctx context.Context, w *models.Workspace) ([]string, string, error)
func (g *GitOps) Checkout(ctx context.Context, w *models.Workspace, branch string) error
```
Credential injection: temporary `.git-credentials` file or `GIT_ASKPASS` script
created for the duration of the operation, then securely wiped. SSH keys use
`GIT_SSH_COMMAND` with a temp key file.
### Git Tools (category: `workspace`)
**`git_status`** — show working tree status
**`git_diff`** — show diff for file or all changes
**`git_commit`** — stage and commit files
**`git_log`** — recent commit history
**`git_branch`** — list/switch branches
These are only injected when the workspace has `git_remote_url` set.
### API Endpoints
```
POST /api/v1/workspaces/:id/git/clone # { url, branch, credential_id }
POST /api/v1/workspaces/:id/git/pull
POST /api/v1/workspaces/:id/git/push
GET /api/v1/workspaces/:id/git/status
GET /api/v1/workspaces/:id/git/diff?path=
POST /api/v1/workspaces/:id/git/commit # { message, paths }
GET /api/v1/workspaces/:id/git/log?n=
GET /api/v1/workspaces/:id/git/branches
POST /api/v1/workspaces/:id/git/checkout # { branch }
# Credential management
POST /api/v1/git-credentials # create
GET /api/v1/git-credentials # list (user-scoped)
DELETE /api/v1/git-credentials/:id # delete
```
### Post-Operation Hooks
After `clone`, `pull`, `checkout`: trigger workspace reconcile (sync FS → DB
index) and re-index changed files (v0.21.2). This ensures the file tree and
search index stay current after git operations.
### Frontend
- Workspace settings: git remote URL, credential picker, clone button.
- Git credential management in user Settings.
- Future (editor surface): git status indicators in file tree, commit UI.
### Checklist
- [ ] `git_credentials` table (Postgres + SQLite migrations)
- [ ] `workspaces` git columns (migration)
- [ ] `GitCredentialStore` interface + both implementations
- [ ] Vault integration for credential encryption/decryption
- [ ] `workspace.GitOps` with exec-based git operations
- [ ] Credential injection: temp `.git-credentials` / `GIT_SSH_COMMAND`
- [ ] Clone with depth option (shallow clone for large repos)
- [ ] Pull, push, status, diff, commit, log, branch, checkout
- [ ] Post-operation reconcile + re-index hook
- [ ] Git tool implementations (5 tools)
- [ ] Git API endpoints (9 endpoints)
- [ ] Credential CRUD endpoints (3 endpoints)
- [ ] Workspace settings UI: git config section
- [ ] User Settings: git credentials management
- [ ] Integration tests: clone, commit, push/pull cycle
- [ ] `.gitignore` respect in workspace indexing (skip ignored files)
- [ ] Security: reject `file://` and local path URLs in clone
---
## v0.21.5 — Editor Surface (Development Mode)
The IDE-like experience. Built entirely as a browser-tier extension consuming
workspace primitives from v0.21.0v0.21.4 and surface infrastructure from
v0.21.3.
### Layout
```
┌──────────────────────────────────────────────────────┐
│ [surface-header] workspace name | branch | status │
├──────────────┬───────────────────┬───────────────────┤
│ File Tree │ Code Editor │ AI Chat Panel │
│ (sidebar) │ (CM6, main) │ (resizable) │
│ │ │ │
│ 📁 src/ │ 1│ package main │ You: refactor │
│ 📄 main.go│ 2│ │ the handler... │
│ 📄 util.go│ 3│ func main() { │ │
│ 📁 tests/ │ 4│ ... │ AI: I'll update │
│ 📄 go.mod │ 5│ } │ main.go... │
│ │ │ │
├──────────────┴───────────────────┴───────────────────┤
│ [surface-footer] file: src/main.go | Ln 4, Col 12 │
└──────────────────────────────────────────────────────┘
```
### Components
**File Tree** (sidebar-content region)
- Tree view backed by `workspace_ls` API (or cached from `Tree()`)
- File icons by extension/MIME type
- Click to open in editor
- Right-click context menu: rename, delete, new file, new folder
- Git status indicators (if git-enabled): modified, staged, untracked
- Drag-drop file reorder (within folders)
**Code Editor** (surface-main region)
- CM6 `codeEditor()` factory (already exists from v0.17.2)
- Language auto-detection from file extension
- Tab bar for multiple open files
- Modified indicator (dot on tab)
- Save: Ctrl/Cmd+S → `workspace_write` API
- Auto-save on tab switch / mode switch (configurable)
**AI Chat Panel** (split pane, resizable)
- Reuses the existing chat rendering pipeline
- Same channel context — conversation history carries over between modes
- Workspace tools auto-injected
- Tool call results update the editor live (if the modified file is open)
**Status Bar** (surface-footer region)
- Current file path
- Cursor position (line, column)
- Language mode
- Encoding
- Git branch (if applicable)
### Extension Manifest
```json
{
"id": "editor-mode",
"name": "Development",
"version": "1.0.0",
"type": "browser",
"surfaces": [{
"id": "editor",
"label": "Editor",
"icon": "code-2",
"regions": ["surface-main", "surface-footer", "sidebar-content", "surface-header"]
}],
"permissions": ["workspace"]
}
```
### Live Update on Tool Calls
When the AI calls `workspace_write` or `workspace_patch` and the target file
is open in the editor, the editor content updates live. Implementation:
1. Tool call completes → workspace write handler fires EventBus event
`workspace.file.changed` with `{ workspaceId, path }`.
2. Editor surface subscribes to this event.
3. On match (file is open), fetch fresh content from API and update CM6
state via `view.dispatch({ changes })`.
### Checklist
- [ ] `editor-mode` extension directory structure
- [ ] File tree component (tree view, icons, context menu)
- [ ] Tab bar component (open files, modified indicator)
- [ ] Split pane layout (editor + chat, resizable divider)
- [ ] CM6 editor integration with language detection
- [ ] Save flow: Ctrl/Cmd+S → API → DB index update
- [ ] Auto-save on tab/mode switch
- [ ] Live update on `workspace.file.changed` event
- [ ] Status bar: file, cursor, language, branch
- [ ] Surface registration via manifest
- [ ] Git status indicators in file tree (if v0.21.4 landed)
- [ ] Mobile: single-pane mode (editor or chat, toggle)
- [ ] Keyboard shortcuts: Ctrl+P (quick open), Ctrl+Shift+F (workspace search)
- [ ] Quick open: fuzzy file finder using `workspace_files` index
---
## v0.21.6 — Article Surface + Document Output Pipeline
Writing-focused surface. The conversation is secondary — the document is the
artifact.
### Document Output Pipeline
Before article mode, we need a way for extensions to produce downloadable
output. This is the pipeline:
```
extension generates content
→ POST /api/v1/workspaces/:id/files/write (save to workspace)
→ or POST /api/v1/workspaces/:id/export (convert + save)
→ user downloads from workspace or gets direct download link
```
Export endpoint supports format conversion (markdown → HTML, markdown → PDF via
pandoc if available, markdown → DOCX). This is a stretch goal — initial version
just saves to workspace and lets users download the raw file.
### Layout
```
┌──────────────────────────────────────────────────────┐
│ [surface-header] Document Title [Export ▾] │
├──────────────┬───────────────────────────────────────┤
│ Outline │ Rich Text Editor (CM6 markdown) │
│ │ │
│ § Intro │ # My Article │
│ § Methods │ │
│ § Data │ Compelling introduction paragraph │
│ § Analysis│ about the topic at hand... │
│ § Results │ │
│ § Discussion│ ## Methods │
│ │ │
│──────────────│ ### Data Collection │
│ AI Assistant │ │
│ │ We gathered data from... │
│ "Expand the │ │
│ methods │ │
│ section..." │ │
├──────────────┴───────────────────────────────────────┤
│ [surface-footer] Words: 1,234 | Reading: ~5 min │
└──────────────────────────────────────────────────────┘
```
### Components
**Outline** (sidebar-content, top section)
- Auto-generated from heading structure in document
- Click to scroll to section
- Drag to reorder sections
**AI Assistant** (sidebar-content, bottom section)
- Lightweight chat interface for document-focused prompts
- Tools: `workspace_read`, `workspace_write`, `workspace_search`
- Additional article tools:
- `suggest_outline` — propose structure for a topic
- `expand_section` — flesh out a heading with content
- `check_citations` — verify URLs are reachable (via url_fetch)
**Rich Text Editor** (surface-main)
- CM6 `noteEditor()` factory (from v0.17.3) with enhanced live preview
- Heading decorations, blockquotes, code blocks, links
- Focus mode: dim everything except current section
**Export** (surface-header dropdown)
- Download as Markdown (.md)
- Download as HTML (rendered)
- Download as PDF (if pandoc available)
- Copy to clipboard (markdown)
### Checklist
- [ ] `article-mode` extension directory structure
- [ ] Outline component (auto-generated from headings, click-to-scroll)
- [ ] Sidebar split: outline (top) + AI assistant (bottom)
- [ ] Rich text editor with enhanced markdown live preview
- [ ] Export dropdown: markdown, HTML, PDF
- [ ] Article-specific tools: `suggest_outline`, `expand_section`
- [ ] Focus mode (dim non-active sections)
- [ ] Word count + reading time in status bar
- [ ] Surface registration via manifest
- [ ] Auto-save document to workspace
- [ ] Mobile: outline collapses to top dropdown
---
## Cross-Cutting Concerns
### Migration Strategy
Each sub-release gets its own migration file(s), both Postgres and SQLite.
Migrations run on backend startup (existing pattern). Numbering continues
from v0.20.0's last migration.
### Test Strategy
- Backend integration tests against both Postgres and SQLite for every store
method and handler (existing CI matrix pattern).
- Workspace FS tests: use a temp directory, not the real PVC.
- Tool execution tests: mock workspace with pre-populated files.
- Surface tests: manual testing + REPL verification (surface infrastructure
is frontend-only, no backend tests).
### Backward Compatibility
- Channels without workspaces work exactly as before.
- Projects without workspaces work exactly as before.
- Archive uploads to channels without workspaces go to attachment store.
- Workspace tools only appear when a workspace is bound.
### Documentation Updates
Each sub-release updates:
- `VERSION``0.21.N`
- `CHANGELOG.md` — detailed entry
- `ARCHITECTURE.md` — workspace section, surface system section
- `EXTENSIONS.md` — §6 update for surface implementation
- `ROADMAP.md` — mark completed items
### Deployment
- PVC: ensure `workspaces/` directory is created at startup
(same pattern as `attachments/` directory creation).
- Git binary: verify `git` is in the Docker image (add to Dockerfile
if not present).
- Embedding role: workspace indexing gracefully degrades when no embedding
model is configured (files still work, search doesn't).
- Quota defaults: `WORKSPACE_MAX_BYTES=524288000` (500MB),
`WORKSPACE_MAX_FILES=10000`.

View File

@@ -78,7 +78,19 @@ v0.19.2 Project Persona Default + Archive + Reorder ✅
v0.20.0 Notifications + @mention Routing + Multi-model ✅ v0.20.0 Notifications + @mention Routing + Multi-model ✅
v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2) v0.21.0 Workspace Storage Primitive ✅
┌───────┴──────────────┐
│ │
v0.21.1 Workspace v0.21.3 Surface Infra
Tools + Bindings + REPL (parallel)
│ │
v0.21.2 Workspace v0.21.5 Editor Surface
Indexing + Search (Development Mode)
│ │
v0.21.4 Git v0.21.6 Article Surface
Integration + Document Output
└───────┬──────────────┘
v0.22.0 Smart Routing + Provider Extensions v0.22.0 Smart Routing + Provider Extensions
@@ -300,7 +312,7 @@ step runs in Docker (esbuild → single IIFE bundle). No change to dev
experience for non-editor code — the rest of `src/js/` stays vanilla. experience for non-editor code — the rest of `src/js/` stays vanilla.
Depends on: nothing (frontend-only). Prerequisite for: extension surfaces Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
(v0.21.0 editor mode). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec. (v0.21.5 editor surface). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec.
**Build Pipeline** **Build Pipeline**
- [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules - [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules
@@ -654,35 +666,96 @@ Depends on: v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x),
--- ---
## v0.21.0 — Extension Surfaces + Modes ## v0.21.x Workspace Platform + Extension Surfaces
Depends on: extension foundation (v0.11.0), CodeMirror 6 (v0.17.2). Seven-release series decomposing workspace storage, tools, indexing, git,
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes). surface infrastructure, and editor/article modes. Full specification in
[DESIGN-0.21.0.md](DESIGN-0.21.0.md).
_(Shifted from v0.18.0 — no content changes)_ Depends on: file storage (v0.12.0), extension foundation (v0.11.0),
CodeMirror 6 (v0.17.2), knowledge base embedding pipeline (v0.14.0).
- [ ] Surface registration and activation API ### v0.21.0 — Workspace Storage Primitive ✅
- [ ] Mode selector in sidebar (below brand, above chat history)
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management Pure backend foundation. Workspaces as a polymorphic platform primitive
owned by users, projects, channels, or teams. Dual-layer storage: PVC
filesystem (source of truth) + DB metadata index (queryable cache).
- [x] `workspaces` table: polymorphic owner (user/project/channel/team), root_path, max_bytes quota, status
- [x] `workspace_files` table: metadata index (path, MIME type, size, sha256, is_directory)
- [x] `WorkspaceStore` interface: CRUD, file index (upsert/delete/list/prefix), ownership lookup, stats
- [x] Postgres + SQLite store implementations with upsert-on-conflict for file index
- [x] `workspace.FS` package: read, write (atomic via temp+rename), delete, mkdir, stat, list, reconcile (FS→DB sync)
- [x] `workspace.Archive`: zip/tar.gz extract + create with archive bomb protection (10K file limit, 100MB single file, quota enforcement), common-prefix stripping
- [x] Content type detection: extension-based (40+ source code types) with http.DetectContentType fallback
- [x] SHA256 computed during write via tee reader (enables content-addressed skip in v0.21.2)
- [x] Path traversal guards: cleanPath + absPath validation, symlink rejection
- [x] 15 API endpoints: workspace CRUD, file CRUD, archive upload/download, reconcile, stats
- [x] Owner-based authorization: user=self, channel=owner, project=member, team=member
- [x] Unit tests: path cleaning, traversal detection, MIME detection, unsafe path filtering, write/read round-trip, delete, mkdir
### v0.21.1 — Workspace Tools + Channel/Project Binding
Make workspaces useful in chat mode via tool calls.
- [ ] Workspace tools (category: workspace): `workspace_ls`, `workspace_read` (50KB soft limit), `workspace_write` (auto-mkdir), `workspace_rm` (recursive guard), `workspace_mv`, `workspace_patch` (find/replace)
- [ ] `channels.workspace_id` FK, `projects.workspace_id` FK
- [ ] Workspace resolution: channel workspace → project workspace (override pattern)
- [ ] Archive upload flow: detect archive MIME + workspace binding → extract to workspace
- [ ] Frontend: channel settings workspace toggle, project Files tab with tree view, chat bar workspace indicator
### v0.21.2 — Workspace Indexing + Semantic Search
Embed text files for semantic search. Reuses `knowledge.SplitText` + `knowledge.Embedder`.
- [ ] `workspace_chunks` table: workspace_id, file_id, chunk_index, content, embedding, metadata
- [ ] `workspace_files` additions: index_status, chunk_count
- [ ] Indexable types: text MIME types + 30+ code extensions
- [ ] Code-aware chunking: larger chunks (1500 chars), function/class boundary separators
- [ ] Content-addressed skip: sha256 check before re-chunking/re-embedding
- [ ] Background indexing: async goroutine with shared semaphore
- [ ] `workspace_search` tool: semantic search with optional glob filtering
### v0.21.3 — Surface Infrastructure + REPL
Pure UI architecture. No workspace dependency (parallel development).
- [ ] Surface registration: `ctx.surfaces.register()` with label, icon, regions, activate/deactivate
- [ ] Region management: `ctx.ui.replace()` / `ctx.ui.restore()` for CM6 state preservation
- [ ] Mode selector in sidebar when ≥1 extension surface registered
- [ ] `surface.activated` / `surface.deactivated` events - [ ] `surface.activated` / `surface.deactivated` events
- [ ] Editor mode (extension: file tree + CM6 code editor + AI chat split pane) - [ ] REPL console ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70)): fourth debug modal tab, AsyncFunction wrapper, injected globals, command history, tab-completion, admin-gated
- [ ] Article mode (extension: outline + rich text + AI assistant panel)
- [ ] Document generation output storage: extension → attachment → download pipeline _(prerequisite for editor/article output)_
**REPL / Debug Console** ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70)) ### v0.21.4 — Git Integration
- [ ] Fourth tab in debug modal (Ctrl+Shift+L): `[ Console | Network | State | REPL ⚠ ]`
- [ ] `AsyncFunction` wrapper for top-level `await` — injects `API`, `Events`, `Extensions`, `DebugLog` as globals Layer git operations onto workspaces.
- [ ] Pretty-print results: collapsible JSON (reuse State tab renderer), errors in red with stack
- [ ] Command history: ↑/↓ navigation, `sessionStorage` (not `localStorage` — no stale credentials across sessions) - [ ] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync
- [ ] Tab-completion on live object graphs: walk `Object.getOwnPropertyNames` + prototype chain, own-first sort - [ ] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM)
- [ ] Event label hints: secondary completion when typing `Events.publish('` or `Events.on('` — sourced from routeTable constants - [ ] Git operations via exec: clone, pull, push, status, diff, commit, log, branch, checkout
- [ ] Every executed command echoed as `REPL:EXEC` entry in Console tab (trace of what ran) - [ ] Workspace tools (git-aware): `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch`
- [ ] Toolbar: Clear (output only), Copy session (full transcript), Help (available globals guide) - [ ] Post-operation hooks: clone/pull/checkout trigger reconcile + re-index changed files
- [ ] Multi-line input: Shift+Enter for newline, Enter to run
- [ ] Access gate: admin-role only (same `isAdmin` check as admin panel) OR `?debug=1` URL param ### v0.21.5 — Editor Surface (Development Mode)
- [ ] _Nice-to-have_: snippets library dropdown ("List channels", "Show current user", "Ping WebSocket", "List loaded extensions")
- [ ] _Nice-to-have_: method signature tooltip from `fn.toString()` parsing on autocomplete selection IDE-like experience. Browser-tier extension consuming workspace primitives.
- [ ] Files: `index.html` (tab markup), `debug.js` (ReplRunner object), `debug.css` (output, input, suggestion dropdown)
- [ ] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar
- [ ] File tree: workspace_ls backed, git status indicators, context menu, drag-drop
- [ ] Code editor: CM6 codeEditor() factory, language auto-detection, tab bar, Ctrl+S save
- [ ] AI chat panel: same channel context, workspace tools auto-injected, live update via EventBus
- [ ] Keyboard shortcuts: Ctrl+P quick open, Ctrl+Shift+F workspace search
### v0.21.6 — Article Surface + Document Output Pipeline
Writing-focused surface. Document is artifact, conversation is secondary.
- [ ] Document output pipeline: write → workspace → export (markdown → HTML/PDF/DOCX via pandoc)
- [ ] Layout: outline (sidebar top), AI assistant (sidebar bottom), rich text editor (CM6 markdown, main)
- [ ] Outline: auto-generated from headings, click-to-scroll, drag to reorder
- [ ] AI assistant: lightweight chat with article-specific tools (suggest_outline, expand_section, check_citations)
- [ ] Rich text editor: CM6 noteEditor() with enhanced live preview, focus mode
- [ ] Export: download as markdown/HTML/PDF, copy to clipboard
--- ---
@@ -721,7 +794,7 @@ The channel foundation for workflows and real-time collaboration. Today
channels are single-user direct chats. This release makes channels a channels are single-user direct chats. This release makes channels a
shared space that multiple actors can inhabit. shared space that multiple actors can inhabit.
Depends on: extension surfaces (v0.21.0), WebSocket infrastructure (already exists). Depends on: extension surfaces (v0.21.3), WebSocket infrastructure (already exists).
_(Shifted from v0.20.0 — no content changes)_ _(Shifted from v0.20.0 — no content changes)_

View File

@@ -0,0 +1,74 @@
-- v0.21.0: Workspaces (platform storage primitive)
--
-- New: workspaces table (polymorphic owner: user, project, channel, team)
-- New: workspace_files table (metadata index — filesystem is source of truth)
-- =========================================
-- 1. Workspaces Table
-- =========================================
CREATE TABLE IF NOT EXISTS workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_type VARCHAR(20) NOT NULL
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
owner_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
root_path TEXT NOT NULL, -- PVC-relative: workspaces/{id}
max_bytes BIGINT, -- quota (NULL = system default)
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived', 'deleting')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
ON workspaces(owner_type, owner_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_status
ON workspaces(status) WHERE status != 'deleting';
DROP TRIGGER IF EXISTS workspaces_updated_at ON workspaces;
CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE workspaces IS 'File workspaces bound to users, projects, channels, or teams';
COMMENT ON COLUMN workspaces.owner_type IS 'Polymorphic owner: user, project, channel, team';
COMMENT ON COLUMN workspaces.root_path IS 'PVC-relative path to workspace root directory';
COMMENT ON COLUMN workspaces.max_bytes IS 'Storage quota in bytes (NULL = system default)';
-- =========================================
-- 2. Workspace Files Table
-- =========================================
-- Metadata index for workspace files. The PVC filesystem is the source
-- of truth; this table enables fast queries (list, filter by type, etc.)
-- and will hold embedding references in v0.21.2.
CREATE TABLE IF NOT EXISTS workspace_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- relative path: src/main.go
is_directory BOOLEAN NOT NULL DEFAULT false,
content_type VARCHAR(100), -- MIME type
size_bytes BIGINT NOT NULL DEFAULT 0,
sha256 VARCHAR(64), -- content hash (NULL for dirs)
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
ON workspace_files(workspace_id, path);
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
ON workspace_files(workspace_id);
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
ON workspace_files(workspace_id, content_type)
WHERE content_type IS NOT NULL;
DROP TRIGGER IF EXISTS workspace_files_updated_at ON workspace_files;
CREATE TRIGGER workspace_files_updated_at BEFORE UPDATE ON workspace_files
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE workspace_files IS 'Metadata index for workspace files — filesystem is source of truth';
COMMENT ON COLUMN workspace_files.path IS 'Relative path from workspace files/ root';
COMMENT ON COLUMN workspace_files.sha256 IS 'Content hash for change detection and dedup';

View File

@@ -0,0 +1,62 @@
-- v0.21.0: Workspaces (platform storage primitive) — SQLite
-- =========================================
-- 1. Workspaces Table
-- =========================================
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
owner_type TEXT NOT NULL
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
root_path TEXT NOT NULL,
max_bytes INTEGER,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived', 'deleting')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
ON workspaces(owner_type, owner_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_status
ON workspaces(status);
CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE workspaces SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 2. Workspace Files Table
-- =========================================
CREATE TABLE IF NOT EXISTS workspace_files (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
path TEXT NOT NULL,
is_directory INTEGER NOT NULL DEFAULT 0,
content_type TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
ON workspace_files(workspace_id, path);
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
ON workspace_files(workspace_id);
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
ON workspace_files(workspace_id, content_type);
CREATE TRIGGER IF NOT EXISTS workspace_files_updated_at AFTER UPDATE ON workspace_files
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE workspace_files SET updated_at = datetime('now') WHERE id = NEW.id;
END;

View File

@@ -0,0 +1,466 @@
package handlers
import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/workspace"
)
// ── Request Types ───────────────────────────
type createWorkspaceRequest struct {
Name string `json:"name" binding:"required,max=200"`
OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"`
OwnerID string `json:"owner_id" binding:"required"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}
type updateWorkspaceRequest = models.WorkspacePatch
// ── Handler ─────────────────────────────────
// WorkspaceHandler handles workspace CRUD, file operations, and archive management.
type WorkspaceHandler struct {
stores store.Stores
wfs *workspace.FS
}
// NewWorkspaceHandler creates a new workspace handler.
func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler {
return &WorkspaceHandler{stores: s, wfs: wfs}
}
// ── Workspace CRUD ──────────────────────────
func (h *WorkspaceHandler) Create(c *gin.Context) {
var req createWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Authorization: verify the caller owns or can access the owner entity
if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
w := &models.Workspace{
OwnerType: req.OwnerType,
OwnerID: req.OwnerID,
Name: req.Name,
MaxBytes: req.MaxBytes,
Status: models.WorkspaceStatusActive,
}
// Pre-generate ID so root_path can be set before DB insert.
// Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()).
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
log.Printf("workspace create: %v", err)
return
}
// Create directory on disk
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace mkdir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"})
return
}
c.JSON(http.StatusCreated, w)
}
func (h *WorkspaceHandler) Get(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Enrich with stats
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err == nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
}
func (h *WorkspaceHandler) Update(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req updateWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *WorkspaceHandler) Delete(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Mark as deleting
status := models.WorkspaceStatusDeleting
h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status})
// Async cleanup (use background context — request ctx will be cancelled)
go func() {
ctx := context.Background()
if err := h.wfs.Destroy(ctx, w); err != nil {
log.Printf("workspace destroy %s: %v", w.ID, err)
}
h.stores.Workspaces.Delete(ctx, w.ID)
log.Printf("workspace deleted: %s", w.ID)
}()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── File Operations ─────────────────────────
func (h *WorkspaceHandler) ListFiles(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
recursive := c.Query("recursive") == "true"
files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"data": files})
}
func (h *WorkspaceHandler) ReadFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type for response header
f, _ := h.wfs.Stat(c.Request.Context(), w, path)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
func (h *WorkspaceHandler) WriteFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
quota := workspace.DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
return
}
if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
}
func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *WorkspaceHandler) Mkdir(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path})
}
// ── Archive Operations ──────────────────────
func (h *WorkspaceHandler) UploadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Accept multipart file upload
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
// Detect format from filename
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (archive extraction needs seekable file for zip)
tmp, err := os.CreateTemp("", "ws-upload-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"files_extracted": count,
})
}
func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
contentType := "application/zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
contentType = "application/gzip"
}
filename := fmt.Sprintf("%s.%s", w.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
_ = contentType // c.File sets the content type from the file
}
// ── Reconcile + Stats ───────────────────────
func (h *WorkspaceHandler) Reconcile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"added": added,
"removed": removed,
"updated": updated,
})
}
func (h *WorkspaceHandler) Stats(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"})
return
}
c.JSON(http.StatusOK, stats)
}
// ── Authorization Helpers ───────────────────
// loadAndAuthorize loads a workspace by :id param and checks user access.
func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) {
wsID := c.Param("id")
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByID(ctx, wsID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"})
}
return nil, false
}
if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return nil, false
}
return w, true
}
// canAccessOwner checks if the current user can access the workspace's owner entity.
func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool {
userID := getUserID(c)
ctx := c.Request.Context()
switch ownerType {
case models.WorkspaceOwnerUser:
return ownerID == userID
case models.WorkspaceOwnerChannel:
// User must own the channel
ch, err := h.stores.Channels.GetByID(ctx, ownerID)
if err != nil {
return false
}
return ch.UserID == userID
case models.WorkspaceOwnerProject:
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs)
return ok
case models.WorkspaceOwnerTeam:
// User must be a member of the team
_, err := h.stores.Teams.GetMember(ctx, ownerID, userID)
return err == nil
default:
return false
}
}
// ── Helpers ─────────────────────────────────
// detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported.
func detectArchiveFormat(filename string) string {
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".zip") {
return "zip"
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz"
}
return ""
}

View File

@@ -29,6 +29,7 @@ import (
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
"git.gobha.me/xcaliber/chat-switchboard/tools" "git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/tools/search" "git.gobha.me/xcaliber/chat-switchboard/tools/search"
"git.gobha.me/xcaliber/chat-switchboard/workspace"
) )
func main() { func main() {
@@ -150,6 +151,20 @@ func main() {
// ── EventBus (created early — needed by role resolver) ── // ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus() bus := events.NewBus()
// ── Workspace FS (v0.21.0) ──────────────
// Provides file operations for workspace storage primitive.
// Nil-safe: handler checks wfs != nil before operating.
var wfs *workspace.FS
if cfg.StoragePath != "" {
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
if err := wfs.Init(); err != nil {
log.Printf("⚠ Workspace FS init failed: %v", err)
wfs = nil
} else {
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
}
}
// 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)
@@ -400,6 +415,24 @@ func main() {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Workspaces (v0.21.0)
if wfs != nil {
wsH := handlers.NewWorkspaceHandler(stores, wfs)
protected.POST("/workspaces", wsH.Create)
protected.GET("/workspaces/:id", wsH.Get)
protected.PATCH("/workspaces/:id", wsH.Update)
protected.DELETE("/workspaces/:id", wsH.Delete)
protected.GET("/workspaces/:id/files", wsH.ListFiles)
protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
protected.GET("/workspaces/:id/stats", wsH.Stats)
}
// Attachments (file upload/download) // Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/attachments", attachH.Upload) protected.POST("/channels/:id/attachments", attachH.Upload)

View File

@@ -0,0 +1,66 @@
package models
import "time"
// =========================================
// WORKSPACES (v0.21.0)
// =========================================
// Workspace owner type constants.
const (
WorkspaceOwnerUser = "user"
WorkspaceOwnerProject = "project"
WorkspaceOwnerChannel = "channel"
WorkspaceOwnerTeam = "team"
)
// Workspace status constants.
const (
WorkspaceStatusActive = "active"
WorkspaceStatusArchived = "archived"
WorkspaceStatusDeleting = "deleting"
)
// Workspace represents a file workspace bound to an owner (user, project, channel, or team).
type Workspace struct {
BaseModel
OwnerType string `json:"owner_type" db:"owner_type"`
OwnerID string `json:"owner_id" db:"owner_id"`
Name string `json:"name" db:"name"`
RootPath string `json:"-" db:"root_path"` // never expose filesystem path
MaxBytes *int64 `json:"max_bytes,omitempty" db:"max_bytes"`
Status string `json:"status" db:"status"`
// Computed fields (not DB columns)
FileCount int `json:"file_count,omitempty"`
TotalBytes int64 `json:"total_bytes,omitempty"`
}
// WorkspacePatch holds optional fields for updating a workspace.
type WorkspacePatch struct {
Name *string `json:"name,omitempty"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
Status *string `json:"status,omitempty"`
}
// WorkspaceFile represents metadata for a single file or directory in a workspace.
type WorkspaceFile struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
Path string `json:"path" db:"path"`
IsDirectory bool `json:"is_directory" db:"is_directory"`
ContentType string `json:"content_type,omitempty" db:"content_type"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
SHA256 string `json:"sha256,omitempty" db:"sha256"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// WorkspaceStats holds aggregate storage metrics for a workspace.
type WorkspaceStats struct {
WorkspaceID string `json:"workspace_id"`
FileCount int `json:"file_count"`
DirCount int `json:"dir_count"`
TotalBytes int64 `json:"total_bytes"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}

View File

@@ -42,6 +42,7 @@ type Stores struct {
Projects ProjectStore Projects ProjectStore
Notifications NotificationStore Notifications NotificationStore
NotifPrefs NotificationPreferenceStore NotifPrefs NotificationPreferenceStore
Workspaces WorkspaceStore
} }
// ========================================= // =========================================
@@ -510,6 +511,33 @@ type NotificationPreferenceStore interface {
Delete(ctx context.Context, userID, notifType string) error Delete(ctx context.Context, userID, notifType string) error
} }
// =========================================
// WORKSPACE STORE (v0.21.0)
// =========================================
type WorkspaceStore interface {
// CRUD
Create(ctx context.Context, w *models.Workspace) error
GetByID(ctx context.Context, id string) (*models.Workspace, error)
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
Delete(ctx context.Context, id string) error
// Ownership lookup
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
// File index
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
DeleteFile(ctx context.Context, workspaceID, path string) error
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
DeleteAllFiles(ctx context.Context, workspaceID string) error
// Stats
GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error)
}
// ========================================= // =========================================
// SHARED TYPES // SHARED TYPES
// ========================================= // =========================================

View File

@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
Projects: NewProjectStore(), Projects: NewProjectStore(),
Notifications: NewNotificationStore(), Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(), NotifPrefs: NewNotificationPreferenceStore(),
Workspaces: NewWorkspaceStore(),
} }
} }

View File

@@ -0,0 +1,272 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type WorkspaceStore struct{}
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
// ── columns ────────────────────────────────
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at`
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, 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.CreatedAt, &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
err := row.Scan(
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
&contentType, &f.SizeBytes, &sha256,
&f.CreatedAt, &f.UpdatedAt,
)
if err != nil {
return nil, err
}
if contentType.Valid {
f.ContentType = contentType.String
}
if sha256.Valid {
f.SHA256 = sha256.String
}
return &f, nil
}
// ── Workspace CRUD ─────────────────────────
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
// Accept pre-generated ID or let Postgres generate one
if w.ID != "" {
return DB.QueryRowContext(ctx, `
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at`,
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
w.MaxBytes, w.Status,
).Scan(&w.CreatedAt, &w.UpdatedAt)
}
return DB.QueryRowContext(ctx, `
INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at`,
w.OwnerType, w.OwnerID, w.Name, w.RootPath,
w.MaxBytes, w.Status,
).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt)
}
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
row := DB.QueryRowContext(ctx,
`SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, 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 !b.HasSets() {
return nil
}
b.Set("updated_at", time.Now().UTC())
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 = $1`, 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 = $1 AND owner_id = $2 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 = $1 AND owner_id = $2 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 {
return DB.QueryRowContext(ctx, `
INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5, $6)
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 = NOW()
RETURNING id, created_at, updated_at`,
f.WorkspaceID, f.Path, f.IsDirectory,
models.NullString(&f.ContentType), f.SizeBytes,
models.NullString(&f.SHA256),
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
}
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`,
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 = $1 AND path LIKE $2`,
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 = $1 AND path = $2`,
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 {
// All files under prefix (recursive)
if prefix == "" {
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = $1 ORDER BY path`
args = []interface{}{workspaceID}
} else {
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path`
args = []interface{}{workspaceID, prefix + "%"}
}
} else {
// Direct children only: match prefix but no additional slashes
if prefix == "" {
// Root level: no slash in path
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path`
args = []interface{}{workspaceID}
} else {
// Children of prefix: starts with prefix, no additional slash after prefix
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
WHERE workspace_id = $1 AND path LIKE $2
AND substring(path FROM %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 = $1`, 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 = $1`,
workspaceID,
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
if err != nil {
return nil, err
}
// Fetch quota
var maxBytes sql.NullInt64
err = DB.QueryRowContext(ctx,
`SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&maxBytes)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if maxBytes.Valid {
stats.MaxBytes = &maxBytes.Int64
}
return stats, nil
}

View File

@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
Projects: NewProjectStore(), Projects: NewProjectStore(),
Notifications: NewNotificationStore(), Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(), NotifPrefs: NewNotificationPreferenceStore(),
Workspaces: NewWorkspaceStore(),
} }
} }

View File

@@ -0,0 +1,274 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"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, created_at, updated_at`
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, 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,
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
err := row.Scan(
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
&contentType, &f.SizeBytes, &sha256,
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
}
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 !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
}

497
server/workspace/archive.go Normal file
View File

@@ -0,0 +1,497 @@
package workspace
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Archive Configuration ───────────────────
const (
// MaxArchiveFiles is the max number of files to extract from an archive.
MaxArchiveFiles = 10000
// MaxArchiveFileSize is the max size of a single file within an archive.
MaxArchiveFileSize int64 = 100 * 1024 * 1024 // 100 MB
)
// ── Extract ─────────────────────────────────
// ExtractArchive extracts a zip or tar.gz archive into the workspace.
// Returns the number of files extracted. Respects workspace quota.
// format must be "zip" or "tar.gz".
func (fs *FS) ExtractArchive(ctx context.Context, w *models.Workspace, archivePath, format string) (int, error) {
switch format {
case "zip":
return fs.extractZip(ctx, w, archivePath)
case "tar.gz", "tgz":
return fs.extractTarGz(ctx, w, archivePath)
default:
return 0, fmt.Errorf("workspace: unsupported archive format: %s", format)
}
}
func (fs *FS) extractZip(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return 0, fmt.Errorf("workspace: open zip: %w", err)
}
defer r.Close()
if len(r.File) > MaxArchiveFiles {
return 0, fmt.Errorf("workspace: archive contains %d files (max %d)", len(r.File), MaxArchiveFiles)
}
// Detect common root prefix (e.g. "project-name/") and strip it
prefix := detectCommonPrefix(zipFileNames(r.File))
root := fs.filesDir(w)
count := 0
var totalBytes int64
for _, f := range r.File {
if err := ctx.Err(); err != nil {
return count, err
}
name := stripPrefix(f.Name, prefix)
if name == "" || name == "." {
continue
}
// Security: reject absolute paths and traversal
if isUnsafePath(name) {
log.Printf("workspace: skipping unsafe zip entry: %s", f.Name)
continue
}
destPath := filepath.Join(root, filepath.FromSlash(name))
if f.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0750); err != nil {
return count, err
}
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: true,
})
continue
}
// Size check
if f.UncompressedSize64 > uint64(MaxArchiveFileSize) {
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, f.UncompressedSize64)
continue
}
// Quota check
quota := DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if totalBytes+int64(f.UncompressedSize64) > quota {
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
}
// Extract file
rc, err := f.Open()
if err != nil {
return count, fmt.Errorf("workspace: open zip entry %s: %w", name, err)
}
n, hash, err := extractToFile(destPath, rc, MaxArchiveFileSize)
rc.Close()
if err != nil {
return count, fmt.Errorf("workspace: extract %s: %w", name, err)
}
totalBytes += n
count++
contentType := detectContentType(name, destPath)
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: false,
ContentType: contentType,
SizeBytes: n,
SHA256: hash,
})
}
log.Printf("workspace: extracted %d files from zip (%d bytes)", count, totalBytes)
return count, nil
}
func (fs *FS) extractTarGz(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
f, err := os.Open(archivePath)
if err != nil {
return 0, fmt.Errorf("workspace: open tar.gz: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, fmt.Errorf("workspace: gzip reader: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
root := fs.filesDir(w)
count := 0
var totalBytes int64
// First pass: collect names to detect common prefix
// Since tar is streaming, we can't do two passes easily.
// We'll detect prefix from the first entry's directory.
var prefix string
prefixDetected := false
for {
if err := ctx.Err(); err != nil {
return count, err
}
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("workspace: tar read: %w", err)
}
if count >= MaxArchiveFiles {
return count, fmt.Errorf("workspace: archive contains more than %d files", MaxArchiveFiles)
}
// Detect prefix from first entry
if !prefixDetected {
if idx := strings.IndexByte(header.Name, '/'); idx > 0 {
prefix = header.Name[:idx+1]
}
prefixDetected = true
}
name := stripPrefix(header.Name, prefix)
if name == "" || name == "." {
continue
}
if isUnsafePath(name) {
log.Printf("workspace: skipping unsafe tar entry: %s", header.Name)
continue
}
destPath := filepath.Join(root, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(destPath, 0750); err != nil {
return count, err
}
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: true,
})
case tar.TypeReg:
if header.Size > MaxArchiveFileSize {
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, header.Size)
continue
}
quota := DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if totalBytes+header.Size > quota {
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
}
n, hash, extractErr := extractToFile(destPath, tr, MaxArchiveFileSize)
if extractErr != nil {
return count, fmt.Errorf("workspace: extract %s: %w", name, extractErr)
}
totalBytes += n
count++
contentType := detectContentType(name, destPath)
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: false,
ContentType: contentType,
SizeBytes: n,
SHA256: hash,
})
default:
// Skip symlinks, devices, etc.
continue
}
}
log.Printf("workspace: extracted %d files from tar.gz (%d bytes)", count, totalBytes)
return count, nil
}
// ── Create Archive ──────────────────────────
// CreateArchive packages the workspace into a zip or tar.gz archive.
// Returns a path to the temporary archive file. Caller must remove it when done.
func (fs *FS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (string, error) {
switch format {
case "zip":
return fs.createZip(ctx, w)
case "tar.gz", "tgz":
return fs.createTarGz(ctx, w)
default:
return "", fmt.Errorf("workspace: unsupported archive format: %s", format)
}
}
func (fs *FS) createZip(ctx context.Context, w *models.Workspace) (string, error) {
root := fs.filesDir(w)
tmp, err := os.CreateTemp("", "ws-*.zip")
if err != nil {
return "", err
}
tmpName := tmp.Name()
zw := zip.NewWriter(tmp)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
if info.IsDir() {
_, err := zw.Create(rel + "/")
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = rel
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
f, err := os.Open(abs)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(writer, f)
return err
})
if closeErr := zw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := tmp.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
os.Remove(tmpName)
return "", fmt.Errorf("workspace: create zip: %w", err)
}
return tmpName, nil
}
func (fs *FS) createTarGz(ctx context.Context, w *models.Workspace) (string, error) {
root := fs.filesDir(w)
tmp, err := os.CreateTemp("", "ws-*.tar.gz")
if err != nil {
return "", err
}
tmpName := tmp.Name()
gw := gzip.NewWriter(tmp)
tw := tar.NewWriter(gw)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = rel
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(abs)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := gw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := tmp.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
os.Remove(tmpName)
return "", fmt.Errorf("workspace: create tar.gz: %w", err)
}
return tmpName, nil
}
// ── Helpers ─────────────────────────────────
// extractToFile writes content from a reader to a file, creating parent dirs.
// Returns bytes written and SHA256 hash.
func extractToFile(destPath string, r io.Reader, maxSize int64) (int64, string, error) {
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
return 0, "", err
}
f, err := os.Create(destPath)
if err != nil {
return 0, "", err
}
defer f.Close()
h := sha256.New()
tee := io.TeeReader(io.LimitReader(r, maxSize+1), h)
n, err := io.Copy(f, tee)
if err != nil {
return n, "", err
}
if n > maxSize {
os.Remove(destPath)
return 0, "", fmt.Errorf("file exceeds max size (%d bytes)", maxSize)
}
return n, hex.EncodeToString(h.Sum(nil)), nil
}
// isUnsafePath returns true if the path contains traversal or unsafe patterns.
func isUnsafePath(p string) bool {
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
return true
}
if strings.Contains(p, "..") {
return true
}
if strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "./") {
// Allow dotfiles like .gitignore, but not .. or hidden dirs as root
// Actually, dotfiles are fine. Let them through.
return false
}
return false
}
// detectCommonPrefix finds a shared directory prefix among file names.
// E.g. ["project/src/a.go", "project/src/b.go"] → "project/"
func detectCommonPrefix(names []string) string {
if len(names) == 0 {
return ""
}
// Check if all files share a common first directory component
var prefix string
for _, name := range names {
idx := strings.IndexByte(name, '/')
if idx < 0 {
// File at root level — no common prefix
return ""
}
dir := name[:idx+1]
if prefix == "" {
prefix = dir
} else if dir != prefix {
return ""
}
}
return prefix
}
// stripPrefix removes the common archive prefix from a file name.
func stripPrefix(name, prefix string) string {
if prefix != "" {
name = strings.TrimPrefix(name, prefix)
}
name = strings.TrimSuffix(name, "/")
name = filepath.ToSlash(name)
return cleanPath(name)
}
// zipFileNames extracts file names from zip entries.
func zipFileNames(files []*zip.File) []string {
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
}

526
server/workspace/fs.go Normal file
View File

@@ -0,0 +1,526 @@
// Package workspace provides filesystem operations for workspaces.
// It operates on the PVC filesystem and keeps the DB metadata index in sync
// via the WorkspaceStore.
package workspace
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Configuration ───────────────────────────
const (
// DefaultMaxBytes is the default workspace quota (500 MB).
DefaultMaxBytes int64 = 500 * 1024 * 1024
// MaxSingleFileBytes is the max size for a single file write (100 MB).
MaxSingleFileBytes int64 = 100 * 1024 * 1024
// filesSubdir is the subdirectory within a workspace root that holds user files.
// Keeps metadata (.workspace.json, future .git/) out of the user's namespace.
filesSubdir = "files"
// metadataFile is the workspace metadata file stored in the workspace root.
metadataFile = ".workspace.json"
)
// ── FS ──────────────────────────────────────
// FS provides filesystem operations for workspaces.
// All file paths are relative to the workspace's files/ subdirectory.
// The DB metadata index is updated on every mutating operation.
type FS struct {
basePath string // e.g. /data/storage/workspaces
store store.WorkspaceStore
}
// NewFS creates a workspace filesystem manager.
func NewFS(basePath string, ws store.WorkspaceStore) *FS {
return &FS{basePath: basePath, store: ws}
}
// Init ensures the base workspaces directory exists.
func (fs *FS) Init() error {
return os.MkdirAll(fs.basePath, 0750)
}
// ── Path Helpers ────────────────────────────
// filesDir returns the absolute path to a workspace's file tree root.
func (fs *FS) filesDir(w *models.Workspace) string {
return filepath.Join(fs.basePath, w.ID, filesSubdir)
}
// absPath resolves a relative workspace path to an absolute filesystem path.
// Returns an error if the path escapes the workspace root (traversal attack).
func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) {
orig := strings.TrimSpace(relPath)
relPath = cleanPath(relPath)
if relPath == "" {
if orig != "" && orig != "." && orig != "./" {
// Non-empty input was sanitized to empty — traversal attempt
return "", fmt.Errorf("workspace: path traversal detected: %s", orig)
}
return fs.filesDir(w), nil
}
root := fs.filesDir(w)
abs := filepath.Join(root, relPath)
// Resolve symlinks and ensure we're still under root
absResolved, err := filepath.Abs(abs)
if err != nil {
return "", fmt.Errorf("workspace: invalid path: %w", err)
}
rootResolved, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("workspace: invalid root: %w", err)
}
if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved {
return "", fmt.Errorf("workspace: path traversal detected: %s", relPath)
}
return abs, nil
}
// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../
func cleanPath(p string) string {
p = strings.TrimSpace(p)
p = path.Clean(p)
p = strings.TrimPrefix(p, "/")
p = strings.TrimPrefix(p, "./")
// Reject paths that resolve outside root
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
return ""
}
if p == "." {
return ""
}
return p
}
// ── Workspace Lifecycle ─────────────────────
// CreateDir creates the workspace directory structure on disk.
// Call after the DB row is created.
func (fs *FS) CreateDir(w *models.Workspace) error {
filesPath := fs.filesDir(w)
return os.MkdirAll(filesPath, 0750)
}
// Destroy removes the entire workspace directory from disk.
// Call after marking the workspace as "deleting" in DB.
func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error {
wsDir := filepath.Join(fs.basePath, w.ID)
// Remove all file index entries first
if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil {
log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err)
}
return os.RemoveAll(wsDir)
}
// ── File Operations ─────────────────────────
// ReadFile returns a reader for the file at relPath.
// Caller must close the returned ReadCloser.
func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) {
abs, err := fs.absPath(w, relPath)
if err != nil {
return nil, 0, err
}
info, err := os.Stat(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath)
}
return nil, 0, err
}
if info.IsDir() {
return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath)
}
f, err := os.Open(abs)
if err != nil {
return nil, 0, err
}
return f, info.Size(), nil
}
// WriteFile creates or overwrites a file at relPath.
// Creates parent directories as needed. Updates the DB metadata index.
func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error {
relPath = cleanPath(relPath)
if relPath == "" {
return fmt.Errorf("workspace: empty file path")
}
abs, err := fs.absPath(w, relPath)
if err != nil {
return err
}
// Reject symlinks at destination
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath)
}
// Ensure parent directory exists
dir := filepath.Dir(abs)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err)
}
// Write to temp file + rename (atomic)
tmp, err := os.CreateTemp(dir, ".ws-*.tmp")
if err != nil {
return fmt.Errorf("workspace: create temp: %w", err)
}
tmpName := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpName) // cleanup on error; no-op after successful rename
}()
// Write content and compute hash simultaneously
h := sha256.New()
tee := io.TeeReader(r, h)
n, err := io.Copy(tmp, tee)
if err != nil {
return fmt.Errorf("workspace: write %s: %w", relPath, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("workspace: close temp: %w", err)
}
// Atomic rename
if err := os.Rename(tmpName, abs); err != nil {
return fmt.Errorf("workspace: rename %s: %w", relPath, err)
}
// Update DB index
contentType := detectContentType(relPath, abs)
hash := hex.EncodeToString(h.Sum(nil))
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: relPath,
IsDirectory: false,
ContentType: contentType,
SizeBytes: n,
SHA256: hash,
})
}
// DeleteFile removes a file or empty directory at relPath.
func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error {
relPath = cleanPath(relPath)
if relPath == "" {
return fmt.Errorf("workspace: cannot delete workspace root")
}
abs, err := fs.absPath(w, relPath)
if err != nil {
return err
}
info, err := os.Stat(abs)
if err != nil {
if os.IsNotExist(err) {
// Clean up DB index anyway
fs.store.DeleteFile(ctx, w.ID, relPath)
return nil
}
return err
}
if info.IsDir() {
if recursive {
if err := os.RemoveAll(abs); err != nil {
return err
}
// Remove all files under this prefix from the index
prefix := relPath
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix)
return fs.store.DeleteFile(ctx, w.ID, relPath)
}
// Non-recursive: only remove empty dirs
if err := os.Remove(abs); err != nil {
return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath)
}
} else {
if err := os.Remove(abs); err != nil {
return err
}
}
return fs.store.DeleteFile(ctx, w.ID, relPath)
}
// Mkdir creates a directory at relPath. Creates parents as needed.
func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error {
relPath = cleanPath(relPath)
if relPath == "" {
return nil // root always exists
}
abs, err := fs.absPath(w, relPath)
if err != nil {
return err
}
if err := os.MkdirAll(abs, 0750); err != nil {
return err
}
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: relPath,
IsDirectory: true,
})
}
// 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)
// Try DB first (fast)
f, err := fs.store.GetFile(ctx, w.ID, relPath)
if err == nil {
return f, nil
}
// Fall back to filesystem
abs, err := fs.absPath(w, relPath)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("workspace: file not found: %s", relPath)
}
return &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: relPath,
IsDirectory: info.IsDir(),
SizeBytes: info.Size(),
ContentType: detectContentType(relPath, abs),
}, nil
}
// ListDir returns files at the given prefix.
func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
prefix = cleanPath(prefix)
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return fs.store.ListFiles(ctx, w.ID, prefix, recursive)
}
// Tree returns all files in the workspace (recursive from root).
func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) {
return fs.store.ListFiles(ctx, w.ID, "", true)
}
// ── Reconcile ───────────────────────────────
// Reconcile walks the filesystem and syncs the DB index.
// Adds missing entries, removes stale entries, updates sizes/hashes.
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
root := fs.filesDir(w)
// Walk FS and collect all paths
fsPaths := make(map[string]os.FileInfo)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return err
}
if rel == "." {
return nil
}
// Normalize to forward slashes
rel = filepath.ToSlash(rel)
fsPaths[rel] = info
return nil
})
if err != nil && !os.IsNotExist(err) {
return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err)
}
// Get all DB entries
dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true)
if err != nil {
return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err)
}
dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles))
for i := range dbFiles {
dbPaths[dbFiles[i].Path] = &dbFiles[i]
}
// Add missing FS entries to DB
for relPath, info := range fsPaths {
if _, exists := dbPaths[relPath]; !exists {
abs := filepath.Join(root, relPath)
f := &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: relPath,
IsDirectory: info.IsDir(),
SizeBytes: info.Size(),
ContentType: detectContentType(relPath, abs),
}
if !info.IsDir() {
if hash, hashErr := hashFile(abs); hashErr == nil {
f.SHA256 = hash
}
}
if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil {
added++
}
}
}
// Remove DB entries not on FS
for dbPath := range dbPaths {
if _, exists := fsPaths[dbPath]; !exists {
if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil {
removed++
}
}
}
log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
return added, removed, updated, nil
}
// ── Content Type Detection ──────────────────
// detectContentType determines the MIME type from extension first,
// then falls back to http.DetectContentType on the first 512 bytes.
func detectContentType(relPath, absPath string) string {
// Extension-based detection first (covers source code)
ext := strings.ToLower(filepath.Ext(relPath))
if ct := mimeByExtension(ext); ct != "" {
return ct
}
// Sniff the first 512 bytes
f, err := os.Open(absPath)
if err != nil {
return "application/octet-stream"
}
defer f.Close()
buf := make([]byte, 512)
n, _ := f.Read(buf)
if n == 0 {
return "application/octet-stream"
}
return http.DetectContentType(buf[:n])
}
// mimeByExtension returns MIME type for common extensions.
// mime.TypeByExtension misses many source code types.
func mimeByExtension(ext string) string {
// Source code extensions that mime.TypeByExtension doesn't know about
codeTypes := map[string]string{
".go": "text/x-go",
".rs": "text/x-rust",
".py": "text/x-python",
".rb": "text/x-ruby",
".java": "text/x-java",
".kt": "text/x-kotlin",
".swift": "text/x-swift",
".c": "text/x-c",
".cpp": "text/x-c++",
".h": "text/x-c",
".hpp": "text/x-c++",
".ts": "text/typescript",
".tsx": "text/typescript",
".jsx": "text/jsx",
".vue": "text/x-vue",
".svelte": "text/x-svelte",
".scala": "text/x-scala",
".pl": "text/x-perl",
".pm": "text/x-perl",
".lua": "text/x-lua",
".zig": "text/x-zig",
".nim": "text/x-nim",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".sh": "text/x-shellscript",
".bash": "text/x-shellscript",
".zsh": "text/x-shellscript",
".fish": "text/x-shellscript",
".sql": "text/x-sql",
".tf": "text/x-terraform",
".hcl": "text/x-hcl",
".proto": "text/x-protobuf",
".graphql": "text/x-graphql",
".toml": "application/toml",
".yaml": "text/yaml",
".yml": "text/yaml",
".dockerfile": "text/x-dockerfile",
".mod": "text/x-go-mod",
".sum": "text/x-go-sum",
".lock": "text/plain",
".gitignore": "text/plain",
".env": "text/plain",
".editorconfig": "text/plain",
}
if ct, ok := codeTypes[ext]; ok {
return ct
}
// Fall back to standard library
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return ""
}
// ── Hashing ─────────────────────────────────
// hashFile computes SHA256 of a file.
func hashFile(absPath string) (string, error) {
f, err := os.Open(absPath)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

388
server/workspace/fs_test.go Normal file
View File

@@ -0,0 +1,388 @@
package workspace
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Path Cleaning Tests ─────────────────────
func TestCleanPath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"src/main.go", "src/main.go"},
{"/src/main.go", "src/main.go"},
{"./src/main.go", "src/main.go"},
{"src/../etc/passwd", "etc/passwd"},
{"../etc/passwd", ""},
{"../..", ""},
{"..", ""},
{".", ""},
{"", ""},
{" src/main.go ", "src/main.go"},
{"src/./main.go", "src/main.go"},
{".gitignore", ".gitignore"},
{".env", ".env"},
{"src/.hidden/file.go", "src/.hidden/file.go"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := cleanPath(tt.input)
if got != tt.want {
t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAbsPathTraversal(t *testing.T) {
tmpDir := t.TempDir()
fs := &FS{basePath: tmpDir}
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-workspace"},
}
// Create the files dir so absPath has a real root to resolve against
filesDir := filepath.Join(tmpDir, w.ID, filesSubdir)
os.MkdirAll(filesDir, 0750)
// Valid paths should work
validPaths := []string{
"src/main.go",
"README.md",
".gitignore",
"deeply/nested/path/file.txt",
}
for _, p := range validPaths {
abs, err := fs.absPath(w, p)
if err != nil {
t.Errorf("absPath(%q) unexpected error: %v", p, err)
continue
}
if !strings.HasPrefix(abs, filesDir) {
t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir)
}
}
// Traversal attempts should fail
traversalPaths := []string{
"../../../etc/passwd",
"src/../../etc/passwd",
}
for _, p := range traversalPaths {
_, err := fs.absPath(w, p)
if err == nil {
t.Errorf("absPath(%q) should have returned error for traversal", p)
}
}
}
// ── Content Type Detection Tests ────────────
func TestMimeByExtension(t *testing.T) {
tests := []struct {
ext string
want string
}{
{".go", "text/x-go"},
{".py", "text/x-python"},
{".rs", "text/x-rust"},
{".ts", "text/typescript"},
{".sh", "text/x-shellscript"},
{".sql", "text/x-sql"},
{".yaml", "text/yaml"},
{".yml", "text/yaml"},
{".toml", "application/toml"},
{".pl", "text/x-perl"},
{".pm", "text/x-perl"},
{".lua", "text/x-lua"},
{".dockerfile", "text/x-dockerfile"},
{".unknown", ""},
}
for _, tt := range tests {
t.Run(tt.ext, func(t *testing.T) {
got := mimeByExtension(tt.ext)
if got != tt.want {
t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want)
}
})
}
}
// ── Unsafe Path Tests ───────────────────────
func TestIsUnsafePath(t *testing.T) {
tests := []struct {
path string
unsafe bool
}{
{"src/main.go", false},
{".gitignore", false},
{".env", false},
{"../etc/passwd", true},
{"/etc/passwd", true},
{"src/../../../etc/passwd", true},
{"\\windows\\system32", true},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := isUnsafePath(tt.path)
if got != tt.unsafe {
t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe)
}
})
}
}
// ── Common Prefix Detection Tests ───────────
func TestDetectCommonPrefix(t *testing.T) {
tests := []struct {
name string
names []string
want string
}{
{
name: "common prefix",
names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"},
want: "project/",
},
{
name: "no common prefix",
names: []string{"src/a.go", "lib/b.go"},
want: "",
},
{
name: "file at root",
names: []string{"project/src/a.go", "Makefile"},
want: "",
},
{
name: "empty",
names: []string{},
want: "",
},
{
name: "single dir",
names: []string{"myproject/file.txt"},
want: "myproject/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectCommonPrefix(tt.names)
if got != tt.want {
t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want)
}
})
}
}
// ── Integration: Write + Read Round-Trip ────
type mockWorkspaceStore struct {
files map[string]*models.WorkspaceFile
}
func newMockStore() *mockWorkspaceStore {
return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)}
}
func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil }
func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
return nil
}
func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil }
func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
return nil, nil
}
func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
key := f.WorkspaceID + ":" + f.Path
m.files[key] = f
return nil
}
func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
delete(m.files, workspaceID+":"+path)
return nil
}
func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
for k := range m.files {
if strings.HasPrefix(k, workspaceID+":"+prefix) {
delete(m.files, k)
}
}
return nil
}
func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
f, ok := m.files[workspaceID+":"+path]
if !ok {
return nil, os.ErrNotExist
}
return f, nil
}
func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
var out []models.WorkspaceFile
for _, f := range m.files {
if f.WorkspaceID == workspaceID {
if prefix == "" || strings.HasPrefix(f.Path, prefix) {
out = append(out, *f)
}
}
}
return out, nil
}
func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
for k, f := range m.files {
if f.WorkspaceID == workspaceID {
delete(m.files, k)
}
}
return nil
}
func TestWriteReadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
Status: "active",
}
if err := wfs.CreateDir(w); err != nil {
t.Fatalf("CreateDir: %v", err)
}
ctx := context.Background()
content := "package main\n\nfunc main() {}\n"
// Write
err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content)))
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Verify DB index was updated
f, ok := mock.files["test-ws:src/main.go"]
if !ok {
t.Fatal("expected file in mock store index")
}
if f.ContentType != "text/x-go" {
t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go")
}
if f.SizeBytes != int64(len(content)) {
t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content))
}
if f.SHA256 == "" {
t.Error("expected non-empty sha256")
}
// Read back
rc, size, err := wfs.ReadFile(ctx, w, "src/main.go")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
defer rc.Close()
if size != int64(len(content)) {
t.Errorf("read size = %d, want %d", size, len(content))
}
buf := make([]byte, size)
if _, err := rc.Read(buf); err != nil {
t.Fatalf("Read: %v", err)
}
if string(buf) != content {
t.Errorf("content = %q, want %q", string(buf), content)
}
}
func TestDeleteFile(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
}
wfs.CreateDir(w)
ctx := context.Background()
content := "hello"
wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content)))
// Delete
err := wfs.DeleteFile(ctx, w, "test.txt", false)
if err != nil {
t.Fatalf("DeleteFile: %v", err)
}
// Verify removed from index
if _, ok := mock.files["test-ws:test.txt"]; ok {
t.Error("expected file removed from index")
}
// Verify removed from filesystem
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt")
if _, err := os.Stat(abs); !os.IsNotExist(err) {
t.Error("expected file removed from filesystem")
}
}
func TestMkdir(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
}
wfs.CreateDir(w)
ctx := context.Background()
err := wfs.Mkdir(ctx, w, "src/pkg/handlers")
if err != nil {
t.Fatalf("Mkdir: %v", err)
}
// Verify on disk
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers")
info, err := os.Stat(abs)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Error("expected directory")
}
// Verify in index
f, ok := mock.files["test-ws:src/pkg/handlers"]
if !ok {
t.Fatal("expected dir in mock store")
}
if !f.IsDirectory {
t.Error("expected is_directory = true")
}
}