diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1b94f..5bfc79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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 ### Added diff --git a/Dockerfile b/Dockerfile index 0c32701..28ae9c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,7 @@ RUN sh /build/scripts/build-editor.sh /build/dist # ── Stage 4: Production ───────────────────── FROM nginx:1-alpine -RUN apk add --no-cache bash +RUN apk add --no-cache bash git # Go backend binary COPY --from=backend /bin/switchboard /usr/local/bin/switchboard diff --git a/VERSION b/VERSION index 5a03fb7..8854156 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.0 +0.21.0 diff --git a/docs/DESIGN-0.21.0.md b/docs/DESIGN-0.21.0.md new file mode 100644 index 0000000..5286186 --- /dev/null +++ b/docs/DESIGN-0.21.0.md @@ -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.0–v0.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.0–v0.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`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b9a585e..8f460fe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -78,7 +78,19 @@ v0.19.2 Project Persona Default + Archive + Reorder ✅ │ 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 │ @@ -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. 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** - [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). -See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes). +Seven-release series decomposing workspace storage, tools, indexing, git, +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 -- [ ] Mode selector in sidebar (below brand, above chat history) -- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management +### v0.21.0 — Workspace Storage Primitive ✅ + +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 -- [ ] Editor mode (extension: file tree + CM6 code editor + AI chat split pane) -- [ ] Article mode (extension: outline + rich text + AI assistant panel) -- [ ] Document generation output storage: extension → attachment → download pipeline _(prerequisite for editor/article output)_ +- [ ] 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 -**REPL / Debug Console** ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70)) -- [ ] 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 -- [ ] 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) -- [ ] Tab-completion on live object graphs: walk `Object.getOwnPropertyNames` + prototype chain, own-first sort -- [ ] Event label hints: secondary completion when typing `Events.publish('` or `Events.on('` — sourced from routeTable constants -- [ ] Every executed command echoed as `REPL:EXEC` entry in Console tab (trace of what ran) -- [ ] Toolbar: Clear (output only), Copy session (full transcript), Help (available globals guide) -- [ ] 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 -- [ ] _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 -- [ ] Files: `index.html` (tab markup), `debug.js` (ReplRunner object), `debug.css` (output, input, suggestion dropdown) +### v0.21.4 — Git Integration + +Layer git operations onto workspaces. + +- [ ] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync +- [ ] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM) +- [ ] Git operations via exec: clone, pull, push, status, diff, commit, log, branch, checkout +- [ ] Workspace tools (git-aware): `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch` +- [ ] Post-operation hooks: clone/pull/checkout trigger reconcile + re-index changed files + +### v0.21.5 — Editor Surface (Development Mode) + +IDE-like experience. Browser-tier extension consuming workspace primitives. + +- [ ] 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 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)_ diff --git a/server/database/migrations/009_v0210_workspaces.sql b/server/database/migrations/009_v0210_workspaces.sql new file mode 100644 index 0000000..00b00d5 --- /dev/null +++ b/server/database/migrations/009_v0210_workspaces.sql @@ -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'; diff --git a/server/database/migrations/sqlite/008_v0210_workspaces.sql b/server/database/migrations/sqlite/008_v0210_workspaces.sql new file mode 100644 index 0000000..e099b4c --- /dev/null +++ b/server/database/migrations/sqlite/008_v0210_workspaces.sql @@ -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; diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go new file mode 100644 index 0000000..f0d7a5a --- /dev/null +++ b/server/handlers/workspaces.go @@ -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 "" +} diff --git a/server/main.go b/server/main.go index 479b502..7da49c0 100644 --- a/server/main.go +++ b/server/main.go @@ -29,6 +29,7 @@ import ( postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" "git.gobha.me/xcaliber/chat-switchboard/tools" "git.gobha.me/xcaliber/chat-switchboard/tools/search" + "git.gobha.me/xcaliber/chat-switchboard/workspace" ) func main() { @@ -150,6 +151,20 @@ func main() { // ── EventBus (created early — needed by role resolver) ── 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) roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus) @@ -400,6 +415,24 @@ func main() { protected.PUT("/notifications/preferences/:type", notifH.SetPreference) 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) attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) protected.POST("/channels/:id/attachments", attachH.Upload) diff --git a/server/models/models_workspace.go b/server/models/models_workspace.go new file mode 100644 index 0000000..6937ac4 --- /dev/null +++ b/server/models/models_workspace.go @@ -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"` +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 8ebec77..534bc97 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -42,6 +42,7 @@ type Stores struct { Projects ProjectStore Notifications NotificationStore NotifPrefs NotificationPreferenceStore + Workspaces WorkspaceStore } // ========================================= @@ -510,6 +511,33 @@ type NotificationPreferenceStore interface { 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 // ========================================= diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 2154044..7caba64 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores { Projects: NewProjectStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), + Workspaces: NewWorkspaceStore(), } } diff --git a/server/store/postgres/workspace.go b/server/store/postgres/workspace.go new file mode 100644 index 0000000..54cd671 --- /dev/null +++ b/server/store/postgres/workspace.go @@ -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 +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 037c87c..6b09b79 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores { Projects: NewProjectStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), + Workspaces: NewWorkspaceStore(), } } diff --git a/server/store/sqlite/workspace.go b/server/store/sqlite/workspace.go new file mode 100644 index 0000000..e69a6fe --- /dev/null +++ b/server/store/sqlite/workspace.go @@ -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 +} diff --git a/server/workspace/archive.go b/server/workspace/archive.go new file mode 100644 index 0000000..8ac46f2 --- /dev/null +++ b/server/workspace/archive.go @@ -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 +} diff --git a/server/workspace/fs.go b/server/workspace/fs.go new file mode 100644 index 0000000..b6d441d --- /dev/null +++ b/server/workspace/fs.go @@ -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 +} diff --git a/server/workspace/fs_test.go b/server/workspace/fs_test.go new file mode 100644 index 0000000..a20468e --- /dev/null +++ b/server/workspace/fs_test.go @@ -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") + } +}