Changeset 0.21.1 (#86)
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.21.0] — 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Workspace Storage Primitive.** Platform-level file storage bound to users, projects, channels, or teams via polymorphic owner model. Dual-layer architecture: PVC filesystem (source of truth) with DB metadata index (queryable cache). Workspaces support configurable quotas, status lifecycle (active/archived/deleting), and owner-based authorization inheritance.
|
||||||
|
- **Workspace data model.** Two new tables: `workspaces` (polymorphic owner_type/owner_id, root_path, max_bytes quota, status) and `workspace_files` (path, MIME type, size, sha256, is_directory). Unique index on (workspace_id, path) enables upsert-on-conflict for file metadata sync. Migrations for both Postgres and SQLite.
|
||||||
|
- **`workspace.FS` package.** Filesystem operations layer with security-first design: atomic writes (temp file + rename with SHA256 computed via tee reader), path traversal guards (cleanPath normalization + absPath containment validation), symlink rejection on write targets. Operations: ReadFile, WriteFile, DeleteFile (with recursive guard), Mkdir, Stat, ListDir, Tree, Reconcile (filesystem→DB drift sync).
|
||||||
|
- **Archive operations.** Extract zip and tar.gz archives into workspaces with bomb protection: 10K file limit, 100MB single file cap, workspace quota enforcement during extraction. Common-prefix stripping handles GitHub-style `project-name/` wrapper directories. CreateArchive packages workspaces into downloadable zip or tar.gz.
|
||||||
|
- **Content type detection.** Extension-based MIME detection covering 40+ source code types (Go, Rust, Python, TypeScript, etc.) with `http.DetectContentType` sniffing fallback for unknown extensions.
|
||||||
|
- **`WorkspaceStore` interface.** Full CRUD for workspaces, file index operations (upsert, delete, delete-by-prefix, get, list with recursive/non-recursive modes), ownership lookup (GetByOwner, ListByOwner), and aggregate stats. Postgres and SQLite implementations.
|
||||||
|
- **Workspace API.** 15 new endpoints under `/api/v1/workspaces`: workspace CRUD (create, get, update, delete), file operations (list, read, write, delete, mkdir), archive management (upload with extraction, download), reconcile (FS→DB sync), stats. Owner-based authorization: user workspaces require self, channel workspaces require channel owner, project workspaces require project member, team workspaces require team member.
|
||||||
|
- **Unit tests.** Path cleaning (13 cases including traversal, dotfiles, whitespace), absPath traversal detection, MIME detection (14 extensions), unsafe path filtering (7 cases), common prefix detection (5 cases), write/read round-trip with mock store, delete verification, mkdir with index sync.
|
||||||
|
|
||||||
## [0.20.0] — 2026-03-01
|
## [0.20.0] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ RUN sh /build/scripts/build-editor.sh /build/dist
|
|||||||
# ── Stage 4: Production ─────────────────────
|
# ── Stage 4: Production ─────────────────────
|
||||||
FROM nginx:1-alpine
|
FROM nginx:1-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache bash
|
RUN apk add --no-cache bash git
|
||||||
|
|
||||||
# Go backend binary
|
# Go backend binary
|
||||||
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
|
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
|
||||||
|
|||||||
976
docs/DESIGN-0.21.0.md
Normal file
976
docs/DESIGN-0.21.0.md
Normal file
@@ -0,0 +1,976 @@
|
|||||||
|
# Design — v0.21.x: Workspaces + Extension Surfaces
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Depends on:** v0.20.0 (notifications), v0.17.2 (CM6), v0.11.0 (extension foundation)
|
||||||
|
**Feeds into:** v0.22.0 (smart routing), v0.23.0 (multi-participant channels)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding Principle
|
||||||
|
|
||||||
|
The **workspace** is a platform primitive — not an editor-mode concept. Even in
|
||||||
|
pure chat mode, uploading a zip, having the AI operate on real files at real
|
||||||
|
paths, and downloading the result as an archive is a first-class workflow. Every
|
||||||
|
surface (chat, editor, article, future modes) benefits from workspace access.
|
||||||
|
|
||||||
|
This changes the layering: workspace infrastructure lands first as foundational
|
||||||
|
storage, then surfaces consume it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Decomposition
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.21.0 Workspace Storage (platform primitive)
|
||||||
|
│
|
||||||
|
v0.21.1 Workspace Tools + Channel/Project Binding
|
||||||
|
│
|
||||||
|
v0.21.2 Workspace Indexing + Semantic Search
|
||||||
|
│
|
||||||
|
v0.21.3 Surface Infrastructure + REPL
|
||||||
|
│
|
||||||
|
v0.21.4 Git Integration
|
||||||
|
│
|
||||||
|
v0.21.5 Editor Surface (Development Mode)
|
||||||
|
│
|
||||||
|
v0.21.6 Article Surface + Document Output Pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.21.0 — Workspace Storage
|
||||||
|
|
||||||
|
Pure backend. No tools, no UI, no surfaces. Just the storage primitive with
|
||||||
|
full CRUD API and archive support.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**`workspaces` table**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | PK |
|
||||||
|
| `owner_type` | TEXT | `user`, `project`, `channel`, `team` |
|
||||||
|
| `owner_id` | UUID | FK polymorphic |
|
||||||
|
| `name` | TEXT | display name |
|
||||||
|
| `root_path` | TEXT | PVC-relative path: `workspaces/{id}` |
|
||||||
|
| `max_bytes` | BIGINT | quota (NULL = system default) |
|
||||||
|
| `status` | TEXT | `active`, `archived`, `deleting` |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
| `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
Index: `(owner_type, owner_id)` — lookup workspace(s) for a given owner.
|
||||||
|
|
||||||
|
**`workspace_files` table** (metadata index — filesystem is source of truth)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | PK |
|
||||||
|
| `workspace_id` | UUID | FK → workspaces |
|
||||||
|
| `path` | TEXT | relative path from workspace root: `src/main.go` |
|
||||||
|
| `is_directory` | BOOLEAN | |
|
||||||
|
| `content_type` | TEXT | MIME type (detected or inferred) |
|
||||||
|
| `size_bytes` | BIGINT | 0 for dirs |
|
||||||
|
| `sha256` | TEXT | content hash (NULL for dirs) |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
| `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
Index: `UNIQUE (workspace_id, path)` — one entry per path.
|
||||||
|
Index: `(workspace_id, content_type)` — filter by type.
|
||||||
|
|
||||||
|
Design note: the DB index is a metadata cache. The PVC filesystem is the source
|
||||||
|
of truth. Syncing happens on write operations and optionally via a reconcile
|
||||||
|
endpoint. This avoids the complexity of fsnotify while keeping queries fast.
|
||||||
|
|
||||||
|
### Filesystem Layout (PVC)
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/storage/
|
||||||
|
attachments/ # existing (v0.12.0)
|
||||||
|
processing/ # existing extraction queue
|
||||||
|
workspaces/ # NEW
|
||||||
|
{workspace_id}/
|
||||||
|
.workspace.json # metadata: owner, quotas, created_at
|
||||||
|
files/ # actual file tree root
|
||||||
|
src/
|
||||||
|
main.go
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Why a separate `files/` subdirectory: keeps workspace metadata (`.workspace.json`,
|
||||||
|
future `.git/` in v0.21.4) out of the user's file namespace.
|
||||||
|
|
||||||
|
### Store Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WorkspaceStore interface {
|
||||||
|
// CRUD
|
||||||
|
Create(ctx context.Context, w *models.Workspace) error
|
||||||
|
GetByID(ctx context.Context, id string) (*models.Workspace, error)
|
||||||
|
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// Ownership lookup
|
||||||
|
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
|
||||||
|
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
|
||||||
|
|
||||||
|
// File index
|
||||||
|
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
|
||||||
|
DeleteFile(ctx context.Context, workspaceID, path string) error
|
||||||
|
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
|
||||||
|
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
|
||||||
|
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
GetDiskUsage(ctx context.Context, workspaceID string) (int64, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workspace FS (Filesystem Operations)
|
||||||
|
|
||||||
|
Separate from the store — this operates on PVC, not DB.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WorkspaceFS struct {
|
||||||
|
basePath string // e.g. /data/storage/workspaces
|
||||||
|
store WorkspaceStore // for metadata sync
|
||||||
|
}
|
||||||
|
|
||||||
|
// File CRUD — all paths relative to workspace root
|
||||||
|
func (fs *WorkspaceFS) ReadFile(ctx context.Context, w *models.Workspace, path string) (io.ReadCloser, int64, error)
|
||||||
|
func (fs *WorkspaceFS) WriteFile(ctx context.Context, w *models.Workspace, path string, r io.Reader, size int64) error
|
||||||
|
func (fs *WorkspaceFS) DeleteFile(ctx context.Context, w *models.Workspace, path string) error
|
||||||
|
func (fs *WorkspaceFS) Mkdir(ctx context.Context, w *models.Workspace, path string) error
|
||||||
|
func (fs *WorkspaceFS) Stat(ctx context.Context, w *models.Workspace, path string) (*models.WorkspaceFile, error)
|
||||||
|
func (fs *WorkspaceFS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||||
|
|
||||||
|
// Tree: returns full file listing with sizes (for file tree UI)
|
||||||
|
func (fs *WorkspaceFS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error)
|
||||||
|
|
||||||
|
// Archive operations
|
||||||
|
func (fs *WorkspaceFS) ExtractArchive(ctx context.Context, w *models.Workspace, r io.Reader, format string) (int, error)
|
||||||
|
func (fs *WorkspaceFS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (io.ReadCloser, error)
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
func (fs *WorkspaceFS) Reconcile(ctx context.Context, w *models.Workspace) error // sync FS → DB index
|
||||||
|
func (fs *WorkspaceFS) Destroy(ctx context.Context, w *models.Workspace) error // rm -rf + DB cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
WriteFile updates the DB index (upsert workspace_files) after successful write.
|
||||||
|
DeleteFile removes the DB row. Reconcile walks the FS and patches any drift.
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/workspaces # create workspace
|
||||||
|
GET /api/v1/workspaces/:id # get metadata
|
||||||
|
PATCH /api/v1/workspaces/:id # update name/quota
|
||||||
|
DELETE /api/v1/workspaces/:id # delete (async cleanup)
|
||||||
|
|
||||||
|
GET /api/v1/workspaces/:id/files?path=&recursive= # list files
|
||||||
|
GET /api/v1/workspaces/:id/files/read?path= # read file content
|
||||||
|
PUT /api/v1/workspaces/:id/files/write?path= # write file (body = content)
|
||||||
|
DELETE /api/v1/workspaces/:id/files?path= # delete file/dir
|
||||||
|
POST /api/v1/workspaces/:id/files/mkdir?path= # create directory
|
||||||
|
|
||||||
|
POST /api/v1/workspaces/:id/archive/upload # upload + extract zip/tar.gz
|
||||||
|
GET /api/v1/workspaces/:id/archive/download?format= # download as zip/tar.gz
|
||||||
|
|
||||||
|
POST /api/v1/workspaces/:id/reconcile # force FS → DB sync
|
||||||
|
GET /api/v1/workspaces/:id/stats # disk usage, file count
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Workspace access inherits from owner: if you can access the project/channel,
|
||||||
|
you can access its workspace.
|
||||||
|
- Path traversal guard: all paths are cleaned and must resolve within the
|
||||||
|
workspace root. Symlinks are rejected.
|
||||||
|
- File size limit: configurable per-workspace `max_bytes` with system default
|
||||||
|
(`WORKSPACE_MAX_BYTES`, default 500MB).
|
||||||
|
- Archive extraction bomb protection: max files (10,000), max total extracted
|
||||||
|
size (workspace quota), max single file (100MB).
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
- `xxx_workspace_tables.sql` (Postgres + SQLite): `workspaces`, `workspace_files`
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `models.Workspace`, `models.WorkspaceFile`, `models.WorkspacePatch` structs
|
||||||
|
- [ ] `WorkspaceStore` interface in `store/interfaces.go`
|
||||||
|
- [ ] Postgres implementation: `store/postgres/workspace.go`
|
||||||
|
- [ ] SQLite implementation: `store/sqlite/workspace.go`
|
||||||
|
- [ ] `WorkspaceFS` in new `server/workspace/` package
|
||||||
|
- [ ] Path traversal validation (must not escape workspace root)
|
||||||
|
- [ ] Archive extract (zip, tar.gz) with bomb protection
|
||||||
|
- [ ] Archive create (zip, tar.gz)
|
||||||
|
- [ ] MIME type detection on write (`net/http.DetectContentType` + extension fallback)
|
||||||
|
- [ ] Workspace CRUD handlers (6 endpoints)
|
||||||
|
- [ ] File CRUD handlers (5 endpoints)
|
||||||
|
- [ ] Archive handlers (2 endpoints)
|
||||||
|
- [ ] Reconcile + stats handlers (2 endpoints)
|
||||||
|
- [ ] `Stores.Workspaces` wired in `store/postgres/stores.go` + `store/sqlite/stores.go`
|
||||||
|
- [ ] Integration tests: workspace CRUD, file CRUD, archive round-trip, path traversal rejection
|
||||||
|
- [ ] PVC directory creation on startup (`/data/storage/workspaces/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.21.1 — Workspace Tools + Channel/Project Binding
|
||||||
|
|
||||||
|
Make workspaces useful in chat mode. AI can operate on files through tool calls.
|
||||||
|
Channels and projects get workspace bindings.
|
||||||
|
|
||||||
|
### Tools (category: `workspace`)
|
||||||
|
|
||||||
|
**`workspace_ls`** — list files in workspace
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "src/",
|
||||||
|
"recursive": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: array of `{ path, is_directory, content_type, size_bytes }`.
|
||||||
|
|
||||||
|
**`workspace_read`** — read a file
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "src/main.go"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: file content as text (with truncation guard for large files, e.g. 50KB
|
||||||
|
soft limit with "file truncated" indicator). Binary files return a base64
|
||||||
|
summary or "binary file, N bytes" message.
|
||||||
|
|
||||||
|
**`workspace_write`** — create or overwrite a file
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "src/main.go",
|
||||||
|
"content": "package main\n\nfunc main() {\n}\n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: confirmation with path and size. Creates parent directories
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
**`workspace_rm`** — delete a file or directory
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "src/old_module/",
|
||||||
|
"recursive": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: confirmation. Recursive required for non-empty directories.
|
||||||
|
|
||||||
|
**`workspace_mv`** — rename or move a file
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "src/old.go",
|
||||||
|
"destination": "src/new.go"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`workspace_patch`** — apply a text diff/patch to a file
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "src/main.go",
|
||||||
|
"operations": [
|
||||||
|
{ "find": "oldFunction()", "replace": "newFunction()" },
|
||||||
|
{ "find": "// TODO: remove", "replace": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `find`/`replace` approach (same pattern as `str_replace` in many AI
|
||||||
|
coding tools) is more reliable for LLMs than unified diffs. Each operation's
|
||||||
|
`find` string must match exactly once in the file.
|
||||||
|
|
||||||
|
### Channel Workspace Binding
|
||||||
|
|
||||||
|
**Schema change:** `channels` table gains optional `workspace_id` FK.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- When a workspace is bound to a channel, workspace tools are injected into
|
||||||
|
the tool set for that channel's completions (same pattern as KB tools via
|
||||||
|
persona binding).
|
||||||
|
- New channel setting: "Workspace" toggle in channel settings.
|
||||||
|
- Creating a workspace from channel settings auto-binds it.
|
||||||
|
|
||||||
|
**Archive upload flow (chat mode):**
|
||||||
|
1. User uploads a zip/tar.gz to a channel with a workspace.
|
||||||
|
2. Upload handler detects archive MIME type + workspace binding.
|
||||||
|
3. Extracts into workspace (not attachment store).
|
||||||
|
4. Workspace tools become available for AI to operate on files.
|
||||||
|
5. User or AI can call archive download to get results.
|
||||||
|
|
||||||
|
Non-archive files still go to attachment store as before (backward compatible).
|
||||||
|
|
||||||
|
### Project Workspace Binding
|
||||||
|
|
||||||
|
**Schema change:** `projects` table gains optional `workspace_id` FK.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Project detail panel gets a "Files" tab showing workspace file tree.
|
||||||
|
- All channels in a project inherit the project workspace (unless the channel
|
||||||
|
has its own).
|
||||||
|
- Workspace resolution: channel workspace > project workspace (same override
|
||||||
|
pattern as persona/KB resolution).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Channel settings: workspace toggle, create/bind workspace.
|
||||||
|
- Project detail panel: Files tab with tree view (reusable component for
|
||||||
|
editor surface later).
|
||||||
|
- Chat bar: workspace indicator when active (similar to KB indicator).
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `workspace_ls` tool implementation
|
||||||
|
- [ ] `workspace_read` tool with text truncation guard + binary detection
|
||||||
|
- [ ] `workspace_write` tool with auto-mkdir
|
||||||
|
- [ ] `workspace_rm` tool with recursive guard
|
||||||
|
- [ ] `workspace_mv` tool
|
||||||
|
- [ ] `workspace_patch` tool with find/replace operations
|
||||||
|
- [ ] Tool registration in `workspace` category
|
||||||
|
- [ ] Tool injection in completion handler when workspace bound
|
||||||
|
- [ ] `channels.workspace_id` column (migration, both dialects)
|
||||||
|
- [ ] `projects.workspace_id` column (migration, both dialects)
|
||||||
|
- [ ] Archive-to-workspace upload handler (detect archive + workspace → extract)
|
||||||
|
- [ ] Workspace resolution: channel → project fallback
|
||||||
|
- [ ] Channel settings UI: workspace toggle
|
||||||
|
- [ ] Project detail panel: Files tab
|
||||||
|
- [ ] Chat bar: workspace indicator
|
||||||
|
- [ ] Integration tests: tool execution, workspace resolution, archive upload flow
|
||||||
|
- [ ] Update `ExecutionContext` with workspace ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.21.2 — Workspace Indexing + Semantic Search
|
||||||
|
|
||||||
|
Embed text files in workspace for semantic search. Reuses the existing
|
||||||
|
`knowledge/` chunker and embedder — they're already decoupled from KB-specific
|
||||||
|
concerns.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**`workspace_chunks` table**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | PK |
|
||||||
|
| `workspace_id` | UUID | FK → workspaces |
|
||||||
|
| `file_id` | UUID | FK → workspace_files |
|
||||||
|
| `chunk_index` | INT | ordinal within file |
|
||||||
|
| `content` | TEXT | chunk text |
|
||||||
|
| `token_count` | INT | estimated |
|
||||||
|
| `embedding` | vector(3072) / JSON | pgvector or JSON text (SQLite) |
|
||||||
|
| `metadata` | JSONB / JSON | extensible: line_start, heading, etc. |
|
||||||
|
|
||||||
|
Index: pgvector IVFFlat on `embedding` (Postgres), app-level cosine (SQLite).
|
||||||
|
Index: `(file_id)` — re-index on file change.
|
||||||
|
|
||||||
|
### Indexing Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
file write/upload
|
||||||
|
→ is text file? (same textMIMETypes + code extensions)
|
||||||
|
→ extract content (already in memory from write, or read from FS)
|
||||||
|
→ chunk (reuse knowledge.SplitText with configurable ChunkConfig)
|
||||||
|
→ embed (reuse knowledge.Embedder)
|
||||||
|
→ store in workspace_chunks (DELETE old chunks for file_id, INSERT new)
|
||||||
|
→ update workspace_files.sha256 (content-addressed skip on no-change)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code-aware MIME types** (extend `textMIMETypes`):
|
||||||
|
|
||||||
|
The existing KB ingester recognizes `text/plain`, `text/markdown`, `text/csv`,
|
||||||
|
`text/html`. For workspace indexing, we also recognize source code by extension:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var indexableExtensions = map[string]bool{
|
||||||
|
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true,
|
||||||
|
".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true,
|
||||||
|
".hpp": true, ".java": true, ".rb": true, ".php": true, ".swift": true,
|
||||||
|
".kt": true, ".scala": true, ".sh": true, ".bash": true, ".zsh": true,
|
||||||
|
".sql": true, ".yaml": true, ".yml": true, ".toml": true, ".json": true,
|
||||||
|
".xml": true, ".css": true, ".scss": true, ".less": true,
|
||||||
|
".md": true, ".txt": true, ".csv": true, ".html": true, ".htm": true,
|
||||||
|
".dockerfile": true, ".tf": true, ".hcl": true, ".proto": true,
|
||||||
|
".graphql": true, ".vue": true, ".svelte": true,
|
||||||
|
".pl": true, ".pm": true, // Perl
|
||||||
|
".lua": true, ".zig": true, ".nim": true, ".ex": true, ".exs": true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content-addressed skip:** if `workspace_files.sha256` matches the new write,
|
||||||
|
skip re-chunking and re-embedding. This makes bulk operations (archive extract)
|
||||||
|
efficient — only changed files get re-indexed.
|
||||||
|
|
||||||
|
**Background indexing:** file writes trigger async indexing (same goroutine
|
||||||
|
pattern as KB ingestion with semaphore). The write API returns immediately; the
|
||||||
|
`workspace_files` row gets an `index_status` column: `pending`, `indexing`,
|
||||||
|
`ready`, `error`, `skipped` (binary/non-text).
|
||||||
|
|
||||||
|
### Code-Aware Chunking
|
||||||
|
|
||||||
|
For source code, the default chunker works reasonably well but we add a
|
||||||
|
code-specific separator hierarchy:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func CodeChunkConfig() ChunkConfig {
|
||||||
|
return ChunkConfig{
|
||||||
|
ChunkSize: 1500, // larger chunks for code (functions can be long)
|
||||||
|
ChunkOverlap: 200,
|
||||||
|
Separators: []string{"\n\nfunc ", "\n\nclass ", "\n\ndef ", "\n\n", "\n", " "},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a simple heuristic — it splits on function/class boundaries when
|
||||||
|
possible, falling back to paragraph and line boundaries. It doesn't require
|
||||||
|
an AST parser and handles most languages well enough for search.
|
||||||
|
|
||||||
|
### Search Tool
|
||||||
|
|
||||||
|
**`workspace_search`** — semantic search across workspace files
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "database connection pooling",
|
||||||
|
"top_k": 10,
|
||||||
|
"file_pattern": "*.go"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: array of `{ file_path, chunk_content, score, line_hint }`.
|
||||||
|
|
||||||
|
The `file_pattern` is optional glob filtering (applied post-search on the
|
||||||
|
`workspace_files.path` join). `line_hint` is approximate line number from
|
||||||
|
chunk metadata.
|
||||||
|
|
||||||
|
### Workspace Store Extensions
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Added to WorkspaceStore interface
|
||||||
|
InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error
|
||||||
|
DeleteChunksByFile(ctx context.Context, fileID string) error
|
||||||
|
SimilaritySearch(ctx context.Context, workspaceID string, embedding []float64, topK int) ([]models.WorkspaceChunkResult, error)
|
||||||
|
UpdateFileIndexStatus(ctx context.Context, fileID, status string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
### `workspace_files` Schema Addition
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `index_status` | TEXT | `pending`, `indexing`, `ready`, `error`, `skipped` |
|
||||||
|
| `chunk_count` | INT | number of chunks after indexing |
|
||||||
|
|
||||||
|
### Indexing Configuration
|
||||||
|
|
||||||
|
- **Per-workspace toggle:** `workspaces.indexing_enabled` (default: `true`
|
||||||
|
when embedding role is configured, `false` otherwise).
|
||||||
|
- **System setting:** `WORKSPACE_INDEXING_ENABLED` env var (global kill switch).
|
||||||
|
- **Concurrency:** shared semaphore with KB ingestion (don't overwhelm the
|
||||||
|
embedding provider).
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] `workspace_chunks` table (Postgres + SQLite migrations)
|
||||||
|
- [ ] `workspace_files.index_status` and `chunk_count` columns
|
||||||
|
- [ ] `WorkspaceChunk`, `WorkspaceChunkResult` model structs
|
||||||
|
- [ ] Store methods: `InsertChunks`, `DeleteChunksByFile`, `SimilaritySearch`, `UpdateFileIndexStatus`
|
||||||
|
- [ ] Postgres implementation with pgvector
|
||||||
|
- [ ] SQLite implementation with app-level cosine
|
||||||
|
- [ ] `workspace.Indexer` — reuses `knowledge.SplitText` + `knowledge.Embedder`
|
||||||
|
- [ ] Indexable type detection: MIME type + extension map
|
||||||
|
- [ ] Code-aware chunk config
|
||||||
|
- [ ] Content-addressed skip (sha256 check)
|
||||||
|
- [ ] Background indexing goroutine with semaphore
|
||||||
|
- [ ] `workspace_search` tool
|
||||||
|
- [ ] `workspace_search` glob filtering on file path
|
||||||
|
- [ ] Hook indexer into `WorkspaceFS.WriteFile` and `ExtractArchive`
|
||||||
|
- [ ] Workspace index status endpoint: `GET /api/v1/workspaces/:id/index-status`
|
||||||
|
- [ ] Integration tests: index pipeline, semantic search, content-addressed skip
|
||||||
|
- [ ] Notification on index complete/error (reuse notification service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.21.3 — Surface Infrastructure + REPL
|
||||||
|
|
||||||
|
Pure UI architecture. No workspace dependency. Can develop in parallel with
|
||||||
|
v0.21.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`.
|
||||||
127
docs/ROADMAP.md
127
docs/ROADMAP.md
@@ -78,7 +78,19 @@ v0.19.2 Project Persona Default + Archive + Reorder ✅
|
|||||||
│
|
│
|
||||||
v0.20.0 Notifications + @mention Routing + Multi-model ✅
|
v0.20.0 Notifications + @mention Routing + Multi-model ✅
|
||||||
│
|
│
|
||||||
v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2)
|
v0.21.0 Workspace Storage Primitive ✅
|
||||||
|
│
|
||||||
|
┌───────┴──────────────┐
|
||||||
|
│ │
|
||||||
|
v0.21.1 Workspace v0.21.3 Surface Infra
|
||||||
|
Tools + Bindings + REPL (parallel)
|
||||||
|
│ │
|
||||||
|
v0.21.2 Workspace v0.21.5 Editor Surface
|
||||||
|
Indexing + Search (Development Mode)
|
||||||
|
│ │
|
||||||
|
v0.21.4 Git v0.21.6 Article Surface
|
||||||
|
Integration + Document Output
|
||||||
|
└───────┬──────────────┘
|
||||||
│
|
│
|
||||||
v0.22.0 Smart Routing + Provider Extensions
|
v0.22.0 Smart Routing + Provider Extensions
|
||||||
│
|
│
|
||||||
@@ -300,7 +312,7 @@ step runs in Docker (esbuild → single IIFE bundle). No change to dev
|
|||||||
experience for non-editor code — the rest of `src/js/` stays vanilla.
|
experience for non-editor code — the rest of `src/js/` stays vanilla.
|
||||||
|
|
||||||
Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
|
Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
|
||||||
(v0.21.0 editor mode). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec.
|
(v0.21.5 editor surface). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec.
|
||||||
|
|
||||||
**Build Pipeline**
|
**Build Pipeline**
|
||||||
- [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules
|
- [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules
|
||||||
@@ -654,35 +666,96 @@ Depends on: v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x),
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.21.0 — Extension Surfaces + Modes
|
## v0.21.x — Workspace Platform + Extension Surfaces
|
||||||
|
|
||||||
Depends on: extension foundation (v0.11.0), CodeMirror 6 (v0.17.2).
|
Seven-release series decomposing workspace storage, tools, indexing, git,
|
||||||
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
|
surface infrastructure, and editor/article modes. Full specification in
|
||||||
|
[DESIGN-0.21.0.md](DESIGN-0.21.0.md).
|
||||||
|
|
||||||
_(Shifted from v0.18.0 — no content changes)_
|
Depends on: file storage (v0.12.0), extension foundation (v0.11.0),
|
||||||
|
CodeMirror 6 (v0.17.2), knowledge base embedding pipeline (v0.14.0).
|
||||||
|
|
||||||
- [ ] Surface registration and activation API
|
### v0.21.0 — Workspace Storage Primitive ✅
|
||||||
- [ ] Mode selector in sidebar (below brand, above chat history)
|
|
||||||
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
|
Pure backend foundation. Workspaces as a polymorphic platform primitive
|
||||||
|
owned by users, projects, channels, or teams. Dual-layer storage: PVC
|
||||||
|
filesystem (source of truth) + DB metadata index (queryable cache).
|
||||||
|
|
||||||
|
- [x] `workspaces` table: polymorphic owner (user/project/channel/team), root_path, max_bytes quota, status
|
||||||
|
- [x] `workspace_files` table: metadata index (path, MIME type, size, sha256, is_directory)
|
||||||
|
- [x] `WorkspaceStore` interface: CRUD, file index (upsert/delete/list/prefix), ownership lookup, stats
|
||||||
|
- [x] Postgres + SQLite store implementations with upsert-on-conflict for file index
|
||||||
|
- [x] `workspace.FS` package: read, write (atomic via temp+rename), delete, mkdir, stat, list, reconcile (FS→DB sync)
|
||||||
|
- [x] `workspace.Archive`: zip/tar.gz extract + create with archive bomb protection (10K file limit, 100MB single file, quota enforcement), common-prefix stripping
|
||||||
|
- [x] Content type detection: extension-based (40+ source code types) with http.DetectContentType fallback
|
||||||
|
- [x] SHA256 computed during write via tee reader (enables content-addressed skip in v0.21.2)
|
||||||
|
- [x] Path traversal guards: cleanPath + absPath validation, symlink rejection
|
||||||
|
- [x] 15 API endpoints: workspace CRUD, file CRUD, archive upload/download, reconcile, stats
|
||||||
|
- [x] Owner-based authorization: user=self, channel=owner, project=member, team=member
|
||||||
|
- [x] Unit tests: path cleaning, traversal detection, MIME detection, unsafe path filtering, write/read round-trip, delete, mkdir
|
||||||
|
|
||||||
|
### v0.21.1 — Workspace Tools + Channel/Project Binding
|
||||||
|
|
||||||
|
Make workspaces useful in chat mode via tool calls.
|
||||||
|
|
||||||
|
- [ ] Workspace tools (category: workspace): `workspace_ls`, `workspace_read` (50KB soft limit), `workspace_write` (auto-mkdir), `workspace_rm` (recursive guard), `workspace_mv`, `workspace_patch` (find/replace)
|
||||||
|
- [ ] `channels.workspace_id` FK, `projects.workspace_id` FK
|
||||||
|
- [ ] Workspace resolution: channel workspace → project workspace (override pattern)
|
||||||
|
- [ ] Archive upload flow: detect archive MIME + workspace binding → extract to workspace
|
||||||
|
- [ ] Frontend: channel settings workspace toggle, project Files tab with tree view, chat bar workspace indicator
|
||||||
|
|
||||||
|
### v0.21.2 — Workspace Indexing + Semantic Search
|
||||||
|
|
||||||
|
Embed text files for semantic search. Reuses `knowledge.SplitText` + `knowledge.Embedder`.
|
||||||
|
|
||||||
|
- [ ] `workspace_chunks` table: workspace_id, file_id, chunk_index, content, embedding, metadata
|
||||||
|
- [ ] `workspace_files` additions: index_status, chunk_count
|
||||||
|
- [ ] Indexable types: text MIME types + 30+ code extensions
|
||||||
|
- [ ] Code-aware chunking: larger chunks (1500 chars), function/class boundary separators
|
||||||
|
- [ ] Content-addressed skip: sha256 check before re-chunking/re-embedding
|
||||||
|
- [ ] Background indexing: async goroutine with shared semaphore
|
||||||
|
- [ ] `workspace_search` tool: semantic search with optional glob filtering
|
||||||
|
|
||||||
|
### v0.21.3 — Surface Infrastructure + REPL
|
||||||
|
|
||||||
|
Pure UI architecture. No workspace dependency (parallel development).
|
||||||
|
|
||||||
|
- [ ] Surface registration: `ctx.surfaces.register()` with label, icon, regions, activate/deactivate
|
||||||
|
- [ ] Region management: `ctx.ui.replace()` / `ctx.ui.restore()` for CM6 state preservation
|
||||||
|
- [ ] Mode selector in sidebar when ≥1 extension surface registered
|
||||||
- [ ] `surface.activated` / `surface.deactivated` events
|
- [ ] `surface.activated` / `surface.deactivated` events
|
||||||
- [ ] Editor mode (extension: file tree + CM6 code editor + AI chat split pane)
|
- [ ] REPL console ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70)): fourth debug modal tab, AsyncFunction wrapper, injected globals, command history, tab-completion, admin-gated
|
||||||
- [ ] Article mode (extension: outline + rich text + AI assistant panel)
|
|
||||||
- [ ] Document generation output storage: extension → attachment → download pipeline _(prerequisite for editor/article output)_
|
|
||||||
|
|
||||||
**REPL / Debug Console** ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70))
|
### v0.21.4 — Git Integration
|
||||||
- [ ] Fourth tab in debug modal (Ctrl+Shift+L): `[ Console | Network | State | REPL ⚠ ]`
|
|
||||||
- [ ] `AsyncFunction` wrapper for top-level `await` — injects `API`, `Events`, `Extensions`, `DebugLog` as globals
|
Layer git operations onto workspaces.
|
||||||
- [ ] Pretty-print results: collapsible JSON (reuse State tab renderer), errors in red with stack
|
|
||||||
- [ ] Command history: ↑/↓ navigation, `sessionStorage` (not `localStorage` — no stale credentials across sessions)
|
- [ ] `workspaces` additions: git_remote_url, git_branch, git_credential_id, git_last_sync
|
||||||
- [ ] Tab-completion on live object graphs: walk `Object.getOwnPropertyNames` + prototype chain, own-first sort
|
- [ ] `git_credentials` table: auth_type (https_pat/https_basic/ssh_key), encrypted_data (AES-256-GCM)
|
||||||
- [ ] Event label hints: secondary completion when typing `Events.publish('` or `Events.on('` — sourced from routeTable constants
|
- [ ] Git operations via exec: clone, pull, push, status, diff, commit, log, branch, checkout
|
||||||
- [ ] Every executed command echoed as `REPL:EXEC` entry in Console tab (trace of what ran)
|
- [ ] Workspace tools (git-aware): `git_status`, `git_diff`, `git_commit`, `git_log`, `git_branch`
|
||||||
- [ ] Toolbar: Clear (output only), Copy session (full transcript), Help (available globals guide)
|
- [ ] Post-operation hooks: clone/pull/checkout trigger reconcile + re-index changed files
|
||||||
- [ ] Multi-line input: Shift+Enter for newline, Enter to run
|
|
||||||
- [ ] Access gate: admin-role only (same `isAdmin` check as admin panel) OR `?debug=1` URL param
|
### v0.21.5 — Editor Surface (Development Mode)
|
||||||
- [ ] _Nice-to-have_: snippets library dropdown ("List channels", "Show current user", "Ping WebSocket", "List loaded extensions")
|
|
||||||
- [ ] _Nice-to-have_: method signature tooltip from `fn.toString()` parsing on autocomplete selection
|
IDE-like experience. Browser-tier extension consuming workspace primitives.
|
||||||
- [ ] Files: `index.html` (tab markup), `debug.js` (ReplRunner object), `debug.css` (output, input, suggestion dropdown)
|
|
||||||
|
- [ ] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar
|
||||||
|
- [ ] File tree: workspace_ls backed, git status indicators, context menu, drag-drop
|
||||||
|
- [ ] Code editor: CM6 codeEditor() factory, language auto-detection, tab bar, Ctrl+S save
|
||||||
|
- [ ] AI chat panel: same channel context, workspace tools auto-injected, live update via EventBus
|
||||||
|
- [ ] Keyboard shortcuts: Ctrl+P quick open, Ctrl+Shift+F workspace search
|
||||||
|
|
||||||
|
### v0.21.6 — Article Surface + Document Output Pipeline
|
||||||
|
|
||||||
|
Writing-focused surface. Document is artifact, conversation is secondary.
|
||||||
|
|
||||||
|
- [ ] Document output pipeline: write → workspace → export (markdown → HTML/PDF/DOCX via pandoc)
|
||||||
|
- [ ] Layout: outline (sidebar top), AI assistant (sidebar bottom), rich text editor (CM6 markdown, main)
|
||||||
|
- [ ] Outline: auto-generated from headings, click-to-scroll, drag to reorder
|
||||||
|
- [ ] AI assistant: lightweight chat with article-specific tools (suggest_outline, expand_section, check_citations)
|
||||||
|
- [ ] Rich text editor: CM6 noteEditor() with enhanced live preview, focus mode
|
||||||
|
- [ ] Export: download as markdown/HTML/PDF, copy to clipboard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -721,7 +794,7 @@ The channel foundation for workflows and real-time collaboration. Today
|
|||||||
channels are single-user direct chats. This release makes channels a
|
channels are single-user direct chats. This release makes channels a
|
||||||
shared space that multiple actors can inhabit.
|
shared space that multiple actors can inhabit.
|
||||||
|
|
||||||
Depends on: extension surfaces (v0.21.0), WebSocket infrastructure (already exists).
|
Depends on: extension surfaces (v0.21.3), WebSocket infrastructure (already exists).
|
||||||
|
|
||||||
_(Shifted from v0.20.0 — no content changes)_
|
_(Shifted from v0.20.0 — no content changes)_
|
||||||
|
|
||||||
|
|||||||
74
server/database/migrations/009_v0210_workspaces.sql
Normal file
74
server/database/migrations/009_v0210_workspaces.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
-- v0.21.0: Workspaces (platform storage primitive)
|
||||||
|
--
|
||||||
|
-- New: workspaces table (polymorphic owner: user, project, channel, team)
|
||||||
|
-- New: workspace_files table (metadata index — filesystem is source of truth)
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 1. Workspaces Table
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_type VARCHAR(20) NOT NULL
|
||||||
|
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||||
|
owner_id UUID NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
root_path TEXT NOT NULL, -- PVC-relative: workspaces/{id}
|
||||||
|
max_bytes BIGINT, -- quota (NULL = system default)
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||||
|
ON workspaces(owner_type, owner_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||||
|
ON workspaces(status) WHERE status != 'deleting';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS workspaces_updated_at ON workspaces;
|
||||||
|
CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
COMMENT ON TABLE workspaces IS 'File workspaces bound to users, projects, channels, or teams';
|
||||||
|
COMMENT ON COLUMN workspaces.owner_type IS 'Polymorphic owner: user, project, channel, team';
|
||||||
|
COMMENT ON COLUMN workspaces.root_path IS 'PVC-relative path to workspace root directory';
|
||||||
|
COMMENT ON COLUMN workspaces.max_bytes IS 'Storage quota in bytes (NULL = system default)';
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 2. Workspace Files Table
|
||||||
|
-- =========================================
|
||||||
|
-- Metadata index for workspace files. The PVC filesystem is the source
|
||||||
|
-- of truth; this table enables fast queries (list, filter by type, etc.)
|
||||||
|
-- and will hold embedding references in v0.21.2.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL, -- relative path: src/main.go
|
||||||
|
is_directory BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
content_type VARCHAR(100), -- MIME type
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
sha256 VARCHAR(64), -- content hash (NULL for dirs)
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||||
|
ON workspace_files(workspace_id, path);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||||
|
ON workspace_files(workspace_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||||
|
ON workspace_files(workspace_id, content_type)
|
||||||
|
WHERE content_type IS NOT NULL;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS workspace_files_updated_at ON workspace_files;
|
||||||
|
CREATE TRIGGER workspace_files_updated_at BEFORE UPDATE ON workspace_files
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
COMMENT ON TABLE workspace_files IS 'Metadata index for workspace files — filesystem is source of truth';
|
||||||
|
COMMENT ON COLUMN workspace_files.path IS 'Relative path from workspace files/ root';
|
||||||
|
COMMENT ON COLUMN workspace_files.sha256 IS 'Content hash for change detection and dedup';
|
||||||
62
server/database/migrations/sqlite/008_v0210_workspaces.sql
Normal file
62
server/database/migrations/sqlite/008_v0210_workspaces.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- v0.21.0: Workspaces (platform storage primitive) — SQLite
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 1. Workspaces Table
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_type TEXT NOT NULL
|
||||||
|
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
root_path TEXT NOT NULL,
|
||||||
|
max_bytes INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||||
|
ON workspaces(owner_type, owner_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||||
|
ON workspaces(status);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces
|
||||||
|
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||||
|
BEGIN
|
||||||
|
UPDATE workspaces SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 2. Workspace Files Table
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
is_directory INTEGER NOT NULL DEFAULT 0,
|
||||||
|
content_type TEXT,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sha256 TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||||
|
ON workspace_files(workspace_id, path);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||||
|
ON workspace_files(workspace_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||||
|
ON workspace_files(workspace_id, content_type);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS workspace_files_updated_at AFTER UPDATE ON workspace_files
|
||||||
|
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||||
|
BEGIN
|
||||||
|
UPDATE workspace_files SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
466
server/handlers/workspaces.go
Normal file
466
server/handlers/workspaces.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Request Types ───────────────────────────
|
||||||
|
|
||||||
|
type createWorkspaceRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,max=200"`
|
||||||
|
OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"`
|
||||||
|
OwnerID string `json:"owner_id" binding:"required"`
|
||||||
|
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateWorkspaceRequest = models.WorkspacePatch
|
||||||
|
|
||||||
|
// ── Handler ─────────────────────────────────
|
||||||
|
|
||||||
|
// WorkspaceHandler handles workspace CRUD, file operations, and archive management.
|
||||||
|
type WorkspaceHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
wfs *workspace.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkspaceHandler creates a new workspace handler.
|
||||||
|
func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler {
|
||||||
|
return &WorkspaceHandler{stores: s, wfs: wfs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace CRUD ──────────────────────────
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||||
|
var req createWorkspaceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: verify the caller owns or can access the owner entity
|
||||||
|
if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &models.Workspace{
|
||||||
|
OwnerType: req.OwnerType,
|
||||||
|
OwnerID: req.OwnerID,
|
||||||
|
Name: req.Name,
|
||||||
|
MaxBytes: req.MaxBytes,
|
||||||
|
Status: models.WorkspaceStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-generate ID so root_path can be set before DB insert.
|
||||||
|
// Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()).
|
||||||
|
w.ID = store.NewID()
|
||||||
|
w.RootPath = "workspaces/" + w.ID
|
||||||
|
|
||||||
|
if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
|
||||||
|
log.Printf("workspace create: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory on disk
|
||||||
|
if err := h.wfs.CreateDir(w); err != nil {
|
||||||
|
log.Printf("workspace mkdir: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with stats
|
||||||
|
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
|
||||||
|
if err == nil {
|
||||||
|
w.FileCount = stats.FileCount
|
||||||
|
w.TotalBytes = stats.TotalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateWorkspaceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as deleting
|
||||||
|
status := models.WorkspaceStatusDeleting
|
||||||
|
h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status})
|
||||||
|
|
||||||
|
// Async cleanup (use background context — request ctx will be cancelled)
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.wfs.Destroy(ctx, w); err != nil {
|
||||||
|
log.Printf("workspace destroy %s: %v", w.ID, err)
|
||||||
|
}
|
||||||
|
h.stores.Workspaces.Delete(ctx, w.ID)
|
||||||
|
log.Printf("workspace deleted: %s", w.ID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Operations ─────────────────────────
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) ListFiles(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Query("path")
|
||||||
|
recursive := c.Query("recursive") == "true"
|
||||||
|
|
||||||
|
files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if files == nil {
|
||||||
|
files = []models.WorkspaceFile{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) ReadFile(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Query("path")
|
||||||
|
if path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Detect content type for response header
|
||||||
|
f, _ := h.wfs.Stat(c.Request.Context(), w, path)
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if f != nil && f.ContentType != "" {
|
||||||
|
contentType = f.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
||||||
|
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) WriteFile(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Query("path")
|
||||||
|
if path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota check
|
||||||
|
stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
|
||||||
|
quota := workspace.DefaultMaxBytes
|
||||||
|
if w.MaxBytes != nil {
|
||||||
|
quota = *w.MaxBytes
|
||||||
|
}
|
||||||
|
if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||||
|
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Query("path")
|
||||||
|
if path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recursive := c.Query("recursive") == "true"
|
||||||
|
|
||||||
|
if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Mkdir(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Query("path")
|
||||||
|
if path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Archive Operations ──────────────────────
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) UploadArchive(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept multipart file upload
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Detect format from filename
|
||||||
|
format := detectArchiveFormat(header.Filename)
|
||||||
|
if format == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file (archive extraction needs seekable file for zip)
|
||||||
|
tmp, err := os.CreateTemp("", "ws-upload-*")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmp, file); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"files_extracted": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := c.DefaultQuery("format", "zip")
|
||||||
|
if format != "zip" && format != "tar.gz" && format != "tgz" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(archivePath)
|
||||||
|
|
||||||
|
ext := "zip"
|
||||||
|
contentType := "application/zip"
|
||||||
|
if format == "tar.gz" || format == "tgz" {
|
||||||
|
ext = "tar.gz"
|
||||||
|
contentType = "application/gzip"
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.%s", w.Name, ext)
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
c.File(archivePath)
|
||||||
|
_ = contentType // c.File sets the content type from the file
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reconcile + Stats ───────────────────────
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Reconcile(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"added": added,
|
||||||
|
"removed": removed,
|
||||||
|
"updated": updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) Stats(c *gin.Context) {
|
||||||
|
w, ok := h.loadAndAuthorize(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authorization Helpers ───────────────────
|
||||||
|
|
||||||
|
// loadAndAuthorize loads a workspace by :id param and checks user access.
|
||||||
|
func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) {
|
||||||
|
wsID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
w, err := h.stores.Workspaces.GetByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"})
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// canAccessOwner checks if the current user can access the workspace's owner entity.
|
||||||
|
func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool {
|
||||||
|
userID := getUserID(c)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
switch ownerType {
|
||||||
|
case models.WorkspaceOwnerUser:
|
||||||
|
return ownerID == userID
|
||||||
|
|
||||||
|
case models.WorkspaceOwnerChannel:
|
||||||
|
// User must own the channel
|
||||||
|
ch, err := h.stores.Channels.GetByID(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ch.UserID == userID
|
||||||
|
|
||||||
|
case models.WorkspaceOwnerProject:
|
||||||
|
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||||
|
ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
case models.WorkspaceOwnerTeam:
|
||||||
|
// User must be a member of the team
|
||||||
|
_, err := h.stores.Teams.GetMember(ctx, ownerID, userID)
|
||||||
|
return err == nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
// detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported.
|
||||||
|
func detectArchiveFormat(filename string) string {
|
||||||
|
lower := strings.ToLower(filename)
|
||||||
|
if strings.HasSuffix(lower, ".zip") {
|
||||||
|
return "zip"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
|
||||||
|
return "tar.gz"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -150,6 +151,20 @@ func main() {
|
|||||||
// ── EventBus (created early — needed by role resolver) ──
|
// ── EventBus (created early — needed by role resolver) ──
|
||||||
bus := events.NewBus()
|
bus := events.NewBus()
|
||||||
|
|
||||||
|
// ── Workspace FS (v0.21.0) ──────────────
|
||||||
|
// Provides file operations for workspace storage primitive.
|
||||||
|
// Nil-safe: handler checks wfs != nil before operating.
|
||||||
|
var wfs *workspace.FS
|
||||||
|
if cfg.StoragePath != "" {
|
||||||
|
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
|
||||||
|
if err := wfs.Init(); err != nil {
|
||||||
|
log.Printf("⚠ Workspace FS init failed: %v", err)
|
||||||
|
wfs = nil
|
||||||
|
} else {
|
||||||
|
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Role resolver for model role dispatch (needs stores + vault + bus)
|
// Role resolver for model role dispatch (needs stores + vault + bus)
|
||||||
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
||||||
|
|
||||||
@@ -400,6 +415,24 @@ func main() {
|
|||||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||||
|
|
||||||
|
// Workspaces (v0.21.0)
|
||||||
|
if wfs != nil {
|
||||||
|
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||||
|
protected.POST("/workspaces", wsH.Create)
|
||||||
|
protected.GET("/workspaces/:id", wsH.Get)
|
||||||
|
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||||
|
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||||
|
protected.GET("/workspaces/:id/files", wsH.ListFiles)
|
||||||
|
protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
|
||||||
|
protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
|
||||||
|
protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
|
||||||
|
protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
|
||||||
|
protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
|
||||||
|
protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
|
||||||
|
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
|
||||||
|
protected.GET("/workspaces/:id/stats", wsH.Stats)
|
||||||
|
}
|
||||||
|
|
||||||
// Attachments (file upload/download)
|
// Attachments (file upload/download)
|
||||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
|
|||||||
66
server/models/models_workspace.go
Normal file
66
server/models/models_workspace.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// WORKSPACES (v0.21.0)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
// Workspace owner type constants.
|
||||||
|
const (
|
||||||
|
WorkspaceOwnerUser = "user"
|
||||||
|
WorkspaceOwnerProject = "project"
|
||||||
|
WorkspaceOwnerChannel = "channel"
|
||||||
|
WorkspaceOwnerTeam = "team"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workspace status constants.
|
||||||
|
const (
|
||||||
|
WorkspaceStatusActive = "active"
|
||||||
|
WorkspaceStatusArchived = "archived"
|
||||||
|
WorkspaceStatusDeleting = "deleting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workspace represents a file workspace bound to an owner (user, project, channel, or team).
|
||||||
|
type Workspace struct {
|
||||||
|
BaseModel
|
||||||
|
OwnerType string `json:"owner_type" db:"owner_type"`
|
||||||
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
RootPath string `json:"-" db:"root_path"` // never expose filesystem path
|
||||||
|
MaxBytes *int64 `json:"max_bytes,omitempty" db:"max_bytes"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
|
||||||
|
// Computed fields (not DB columns)
|
||||||
|
FileCount int `json:"file_count,omitempty"`
|
||||||
|
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspacePatch holds optional fields for updating a workspace.
|
||||||
|
type WorkspacePatch struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceFile represents metadata for a single file or directory in a workspace.
|
||||||
|
type WorkspaceFile struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
||||||
|
Path string `json:"path" db:"path"`
|
||||||
|
IsDirectory bool `json:"is_directory" db:"is_directory"`
|
||||||
|
ContentType string `json:"content_type,omitempty" db:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||||
|
SHA256 string `json:"sha256,omitempty" db:"sha256"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceStats holds aggregate storage metrics for a workspace.
|
||||||
|
type WorkspaceStats struct {
|
||||||
|
WorkspaceID string `json:"workspace_id"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
DirCount int `json:"dir_count"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ type Stores struct {
|
|||||||
Projects ProjectStore
|
Projects ProjectStore
|
||||||
Notifications NotificationStore
|
Notifications NotificationStore
|
||||||
NotifPrefs NotificationPreferenceStore
|
NotifPrefs NotificationPreferenceStore
|
||||||
|
Workspaces WorkspaceStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -510,6 +511,33 @@ type NotificationPreferenceStore interface {
|
|||||||
Delete(ctx context.Context, userID, notifType string) error
|
Delete(ctx context.Context, userID, notifType string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// WORKSPACE STORE (v0.21.0)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type WorkspaceStore interface {
|
||||||
|
// CRUD
|
||||||
|
Create(ctx context.Context, w *models.Workspace) error
|
||||||
|
GetByID(ctx context.Context, id string) (*models.Workspace, error)
|
||||||
|
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// Ownership lookup
|
||||||
|
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
|
||||||
|
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
|
||||||
|
|
||||||
|
// File index
|
||||||
|
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
|
||||||
|
DeleteFile(ctx context.Context, workspaceID, path string) error
|
||||||
|
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
|
||||||
|
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
|
||||||
|
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||||
|
DeleteAllFiles(ctx context.Context, workspaceID string) error
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error)
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// SHARED TYPES
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Projects: NewProjectStore(),
|
Projects: NewProjectStore(),
|
||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
|
Workspaces: NewWorkspaceStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
272
server/store/postgres/workspace.go
Normal file
272
server/store/postgres/workspace.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceStore struct{}
|
||||||
|
|
||||||
|
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
||||||
|
|
||||||
|
// ── columns ────────────────────────────────
|
||||||
|
|
||||||
|
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at`
|
||||||
|
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at`
|
||||||
|
|
||||||
|
// ── scanners ───────────────────────────────
|
||||||
|
|
||||||
|
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||||
|
var w models.Workspace
|
||||||
|
var maxBytes sql.NullInt64
|
||||||
|
err := row.Scan(
|
||||||
|
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||||
|
&w.RootPath, &maxBytes, &w.Status,
|
||||||
|
&w.CreatedAt, &w.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxBytes.Valid {
|
||||||
|
w.MaxBytes = &maxBytes.Int64
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
||||||
|
var f models.WorkspaceFile
|
||||||
|
var contentType sql.NullString
|
||||||
|
var sha256 sql.NullString
|
||||||
|
err := row.Scan(
|
||||||
|
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
||||||
|
&contentType, &f.SizeBytes, &sha256,
|
||||||
|
&f.CreatedAt, &f.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if contentType.Valid {
|
||||||
|
f.ContentType = contentType.String
|
||||||
|
}
|
||||||
|
if sha256.Valid {
|
||||||
|
f.SHA256 = sha256.String
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace CRUD ─────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
||||||
|
// Accept pre-generated ID or let Postgres generate one
|
||||||
|
if w.ID != "" {
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING created_at, updated_at`,
|
||||||
|
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||||
|
w.MaxBytes, w.Status,
|
||||||
|
).Scan(&w.CreatedAt, &w.UpdatedAt)
|
||||||
|
}
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, created_at, updated_at`,
|
||||||
|
w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||||
|
w.MaxBytes, w.Status,
|
||||||
|
).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id)
|
||||||
|
return scanWorkspace(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||||
|
b := NewUpdate("workspaces")
|
||||||
|
if patch.Name != nil {
|
||||||
|
b.Set("name", *patch.Name)
|
||||||
|
}
|
||||||
|
if patch.MaxBytes != nil {
|
||||||
|
b.Set("max_bytes", *patch.MaxBytes)
|
||||||
|
}
|
||||||
|
if patch.Status != nil {
|
||||||
|
b.Set("status", *patch.Status)
|
||||||
|
}
|
||||||
|
if !b.HasSets() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.Set("updated_at", time.Now().UTC())
|
||||||
|
b.Where("id", id)
|
||||||
|
_, err := b.Exec(DB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ownership lookup ───────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces
|
||||||
|
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
ownerType, ownerID)
|
||||||
|
return scanWorkspace(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces
|
||||||
|
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
ownerType, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.Workspace
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWorkspace(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *w)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File index ─────────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
||||||
|
is_directory = EXCLUDED.is_directory,
|
||||||
|
content_type = EXCLUDED.content_type,
|
||||||
|
size_bytes = EXCLUDED.size_bytes,
|
||||||
|
sha256 = EXCLUDED.sha256,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, created_at, updated_at`,
|
||||||
|
f.WorkspaceID, f.Path, f.IsDirectory,
|
||||||
|
models.NullString(&f.ContentType), f.SizeBytes,
|
||||||
|
models.NullString(&f.SHA256),
|
||||||
|
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`,
|
||||||
|
workspaceID, path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`,
|
||||||
|
workspaceID, prefix+"%")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||||
|
WHERE workspace_id = $1 AND path = $2`,
|
||||||
|
workspaceID, path)
|
||||||
|
return scanWorkspaceFile(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if recursive {
|
||||||
|
// All files under prefix (recursive)
|
||||||
|
if prefix == "" {
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = $1 ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID}
|
||||||
|
} else {
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID, prefix + "%"}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct children only: match prefix but no additional slashes
|
||||||
|
if prefix == "" {
|
||||||
|
// Root level: no slash in path
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID}
|
||||||
|
} else {
|
||||||
|
// Children of prefix: starts with prefix, no additional slash after prefix
|
||||||
|
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||||
|
WHERE workspace_id = $1 AND path LIKE $2
|
||||||
|
AND substring(path FROM %d) NOT LIKE '%%/%%'
|
||||||
|
ORDER BY path`, len(prefix)+1)
|
||||||
|
args = []interface{}{workspaceID, prefix + "%"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := DB.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.WorkspaceFile
|
||||||
|
for rows.Next() {
|
||||||
|
f, err := scanWorkspaceFile(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||||
|
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(size_bytes), 0)
|
||||||
|
FROM workspace_files WHERE workspace_id = $1`,
|
||||||
|
workspaceID,
|
||||||
|
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch quota
|
||||||
|
var maxBytes sql.NullInt64
|
||||||
|
err = DB.QueryRowContext(ctx,
|
||||||
|
`SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID,
|
||||||
|
).Scan(&maxBytes)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxBytes.Valid {
|
||||||
|
stats.MaxBytes = &maxBytes.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Projects: NewProjectStore(),
|
Projects: NewProjectStore(),
|
||||||
Notifications: NewNotificationStore(),
|
Notifications: NewNotificationStore(),
|
||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
|
Workspaces: NewWorkspaceStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
server/store/sqlite/workspace.go
Normal file
274
server/store/sqlite/workspace.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceStore struct{}
|
||||||
|
|
||||||
|
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
||||||
|
|
||||||
|
// ── columns ────────────────────────────────
|
||||||
|
|
||||||
|
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at`
|
||||||
|
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at`
|
||||||
|
|
||||||
|
// ── scanners ───────────────────────────────
|
||||||
|
|
||||||
|
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||||
|
var w models.Workspace
|
||||||
|
var maxBytes sql.NullInt64
|
||||||
|
err := row.Scan(
|
||||||
|
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||||
|
&w.RootPath, &maxBytes, &w.Status,
|
||||||
|
st(&w.CreatedAt), st(&w.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxBytes.Valid {
|
||||||
|
w.MaxBytes = &maxBytes.Int64
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
||||||
|
var f models.WorkspaceFile
|
||||||
|
var contentType sql.NullString
|
||||||
|
var sha256 sql.NullString
|
||||||
|
err := row.Scan(
|
||||||
|
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
||||||
|
&contentType, &f.SizeBytes, &sha256,
|
||||||
|
st(&f.CreatedAt), st(&f.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if contentType.Valid {
|
||||||
|
f.ContentType = contentType.String
|
||||||
|
}
|
||||||
|
if sha256.Valid {
|
||||||
|
f.SHA256 = sha256.String
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace CRUD ─────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
||||||
|
if w.ID == "" {
|
||||||
|
w.ID = store.NewID()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
w.CreatedAt = now
|
||||||
|
w.UpdatedAt = now
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||||
|
w.MaxBytes, w.Status,
|
||||||
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id)
|
||||||
|
return scanWorkspace(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||||
|
b := NewUpdate("workspaces")
|
||||||
|
if patch.Name != nil {
|
||||||
|
b.Set("name", *patch.Name)
|
||||||
|
}
|
||||||
|
if patch.MaxBytes != nil {
|
||||||
|
b.Set("max_bytes", *patch.MaxBytes)
|
||||||
|
}
|
||||||
|
if patch.Status != nil {
|
||||||
|
b.Set("status", *patch.Status)
|
||||||
|
}
|
||||||
|
if !b.HasSets() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.SetExpr("updated_at", nowExpr)
|
||||||
|
b.Where("id", id)
|
||||||
|
_, err := b.Exec(DB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ownership lookup ───────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces
|
||||||
|
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
ownerType, ownerID)
|
||||||
|
return scanWorkspace(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+workspaceCols+` FROM workspaces
|
||||||
|
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
ownerType, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.Workspace
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWorkspace(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *w)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File index ─────────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||||
|
if f.ID == "" {
|
||||||
|
f.ID = store.NewID()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
f.CreatedAt = now
|
||||||
|
f.UpdatedAt = now
|
||||||
|
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
||||||
|
is_directory = excluded.is_directory,
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
sha256 = excluded.sha256,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
f.ID, f.WorkspaceID, f.Path, f.IsDirectory,
|
||||||
|
models.NullString(&f.ContentType), f.SizeBytes,
|
||||||
|
models.NullString(&f.SHA256),
|
||||||
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`,
|
||||||
|
workspaceID, path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`,
|
||||||
|
workspaceID, prefix+"%")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||||
|
WHERE workspace_id = ? AND path = ?`,
|
||||||
|
workspaceID, path)
|
||||||
|
return scanWorkspaceFile(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if recursive {
|
||||||
|
if prefix == "" {
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = ? ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID}
|
||||||
|
} else {
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = ? AND path LIKE ? ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID, prefix + "%"}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if prefix == "" {
|
||||||
|
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||||
|
WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path`
|
||||||
|
args = []interface{}{workspaceID}
|
||||||
|
} else {
|
||||||
|
// Direct children: starts with prefix, no slash after prefix
|
||||||
|
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||||
|
WHERE workspace_id = ? AND path LIKE ?
|
||||||
|
AND substr(path, %d) NOT LIKE '%%/%%'
|
||||||
|
ORDER BY path`, len(prefix)+1)
|
||||||
|
args = []interface{}{workspaceID, prefix + "%"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := DB.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.WorkspaceFile
|
||||||
|
for rows.Next() {
|
||||||
|
f, err := scanWorkspaceFile(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────
|
||||||
|
|
||||||
|
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||||
|
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(size_bytes), 0)
|
||||||
|
FROM workspace_files WHERE workspace_id = ?`,
|
||||||
|
workspaceID,
|
||||||
|
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxBytes sql.NullInt64
|
||||||
|
err = DB.QueryRowContext(ctx,
|
||||||
|
`SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID,
|
||||||
|
).Scan(&maxBytes)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxBytes.Valid {
|
||||||
|
stats.MaxBytes = &maxBytes.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
497
server/workspace/archive.go
Normal file
497
server/workspace/archive.go
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Archive Configuration ───────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxArchiveFiles is the max number of files to extract from an archive.
|
||||||
|
MaxArchiveFiles = 10000
|
||||||
|
|
||||||
|
// MaxArchiveFileSize is the max size of a single file within an archive.
|
||||||
|
MaxArchiveFileSize int64 = 100 * 1024 * 1024 // 100 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Extract ─────────────────────────────────
|
||||||
|
|
||||||
|
// ExtractArchive extracts a zip or tar.gz archive into the workspace.
|
||||||
|
// Returns the number of files extracted. Respects workspace quota.
|
||||||
|
// format must be "zip" or "tar.gz".
|
||||||
|
func (fs *FS) ExtractArchive(ctx context.Context, w *models.Workspace, archivePath, format string) (int, error) {
|
||||||
|
switch format {
|
||||||
|
case "zip":
|
||||||
|
return fs.extractZip(ctx, w, archivePath)
|
||||||
|
case "tar.gz", "tgz":
|
||||||
|
return fs.extractTarGz(ctx, w, archivePath)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("workspace: unsupported archive format: %s", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) extractZip(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
|
||||||
|
r, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("workspace: open zip: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if len(r.File) > MaxArchiveFiles {
|
||||||
|
return 0, fmt.Errorf("workspace: archive contains %d files (max %d)", len(r.File), MaxArchiveFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect common root prefix (e.g. "project-name/") and strip it
|
||||||
|
prefix := detectCommonPrefix(zipFileNames(r.File))
|
||||||
|
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
count := 0
|
||||||
|
var totalBytes int64
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := stripPrefix(f.Name, prefix)
|
||||||
|
if name == "" || name == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: reject absolute paths and traversal
|
||||||
|
if isUnsafePath(name) {
|
||||||
|
log.Printf("workspace: skipping unsafe zip entry: %s", f.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(destPath, 0750); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: name,
|
||||||
|
IsDirectory: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size check
|
||||||
|
if f.UncompressedSize64 > uint64(MaxArchiveFileSize) {
|
||||||
|
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, f.UncompressedSize64)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota check
|
||||||
|
quota := DefaultMaxBytes
|
||||||
|
if w.MaxBytes != nil {
|
||||||
|
quota = *w.MaxBytes
|
||||||
|
}
|
||||||
|
if totalBytes+int64(f.UncompressedSize64) > quota {
|
||||||
|
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return count, fmt.Errorf("workspace: open zip entry %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, hash, err := extractToFile(destPath, rc, MaxArchiveFileSize)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return count, fmt.Errorf("workspace: extract %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes += n
|
||||||
|
count++
|
||||||
|
|
||||||
|
contentType := detectContentType(name, destPath)
|
||||||
|
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: name,
|
||||||
|
IsDirectory: false,
|
||||||
|
ContentType: contentType,
|
||||||
|
SizeBytes: n,
|
||||||
|
SHA256: hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("workspace: extracted %d files from zip (%d bytes)", count, totalBytes)
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) extractTarGz(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("workspace: open tar.gz: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("workspace: gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
count := 0
|
||||||
|
var totalBytes int64
|
||||||
|
|
||||||
|
// First pass: collect names to detect common prefix
|
||||||
|
// Since tar is streaming, we can't do two passes easily.
|
||||||
|
// We'll detect prefix from the first entry's directory.
|
||||||
|
var prefix string
|
||||||
|
prefixDetected := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return count, fmt.Errorf("workspace: tar read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= MaxArchiveFiles {
|
||||||
|
return count, fmt.Errorf("workspace: archive contains more than %d files", MaxArchiveFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect prefix from first entry
|
||||||
|
if !prefixDetected {
|
||||||
|
if idx := strings.IndexByte(header.Name, '/'); idx > 0 {
|
||||||
|
prefix = header.Name[:idx+1]
|
||||||
|
}
|
||||||
|
prefixDetected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
name := stripPrefix(header.Name, prefix)
|
||||||
|
if name == "" || name == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isUnsafePath(name) {
|
||||||
|
log.Printf("workspace: skipping unsafe tar entry: %s", header.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(destPath, 0750); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: name,
|
||||||
|
IsDirectory: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
case tar.TypeReg:
|
||||||
|
if header.Size > MaxArchiveFileSize {
|
||||||
|
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, header.Size)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
quota := DefaultMaxBytes
|
||||||
|
if w.MaxBytes != nil {
|
||||||
|
quota = *w.MaxBytes
|
||||||
|
}
|
||||||
|
if totalBytes+header.Size > quota {
|
||||||
|
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, hash, extractErr := extractToFile(destPath, tr, MaxArchiveFileSize)
|
||||||
|
if extractErr != nil {
|
||||||
|
return count, fmt.Errorf("workspace: extract %s: %w", name, extractErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes += n
|
||||||
|
count++
|
||||||
|
|
||||||
|
contentType := detectContentType(name, destPath)
|
||||||
|
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: name,
|
||||||
|
IsDirectory: false,
|
||||||
|
ContentType: contentType,
|
||||||
|
SizeBytes: n,
|
||||||
|
SHA256: hash,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Skip symlinks, devices, etc.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("workspace: extracted %d files from tar.gz (%d bytes)", count, totalBytes)
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Archive ──────────────────────────
|
||||||
|
|
||||||
|
// CreateArchive packages the workspace into a zip or tar.gz archive.
|
||||||
|
// Returns a path to the temporary archive file. Caller must remove it when done.
|
||||||
|
func (fs *FS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (string, error) {
|
||||||
|
switch format {
|
||||||
|
case "zip":
|
||||||
|
return fs.createZip(ctx, w)
|
||||||
|
case "tar.gz", "tgz":
|
||||||
|
return fs.createTarGz(ctx, w)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("workspace: unsupported archive format: %s", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) createZip(ctx context.Context, w *models.Workspace) (string, error) {
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "ws-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(tmp)
|
||||||
|
|
||||||
|
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(root, abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
_, err := zw.Create(rel + "/")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = rel
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
|
writer, err := zw.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, f)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if closeErr := zw.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if closeErr := tmp.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return "", fmt.Errorf("workspace: create zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FS) createTarGz(ctx context.Context, w *models.Workspace) (string, error) {
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "ws-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(tmp)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(root, abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = rel
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(tw, f)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if closeErr := tw.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if closeErr := gw.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if closeErr := tmp.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return "", fmt.Errorf("workspace: create tar.gz: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
// extractToFile writes content from a reader to a file, creating parent dirs.
|
||||||
|
// Returns bytes written and SHA256 hash.
|
||||||
|
func extractToFile(destPath string, r io.Reader, maxSize int64) (int64, string, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
tee := io.TeeReader(io.LimitReader(r, maxSize+1), h)
|
||||||
|
|
||||||
|
n, err := io.Copy(f, tee)
|
||||||
|
if err != nil {
|
||||||
|
return n, "", err
|
||||||
|
}
|
||||||
|
if n > maxSize {
|
||||||
|
os.Remove(destPath)
|
||||||
|
return 0, "", fmt.Errorf("file exceeds max size (%d bytes)", maxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUnsafePath returns true if the path contains traversal or unsafe patterns.
|
||||||
|
func isUnsafePath(p string) bool {
|
||||||
|
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "..") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "./") {
|
||||||
|
// Allow dotfiles like .gitignore, but not .. or hidden dirs as root
|
||||||
|
// Actually, dotfiles are fine. Let them through.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectCommonPrefix finds a shared directory prefix among file names.
|
||||||
|
// E.g. ["project/src/a.go", "project/src/b.go"] → "project/"
|
||||||
|
func detectCommonPrefix(names []string) string {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all files share a common first directory component
|
||||||
|
var prefix string
|
||||||
|
for _, name := range names {
|
||||||
|
idx := strings.IndexByte(name, '/')
|
||||||
|
if idx < 0 {
|
||||||
|
// File at root level — no common prefix
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir := name[:idx+1]
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = dir
|
||||||
|
} else if dir != prefix {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripPrefix removes the common archive prefix from a file name.
|
||||||
|
func stripPrefix(name, prefix string) string {
|
||||||
|
if prefix != "" {
|
||||||
|
name = strings.TrimPrefix(name, prefix)
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, "/")
|
||||||
|
name = filepath.ToSlash(name)
|
||||||
|
return cleanPath(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipFileNames extracts file names from zip entries.
|
||||||
|
func zipFileNames(files []*zip.File) []string {
|
||||||
|
names := make([]string, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
names[i] = f.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
526
server/workspace/fs.go
Normal file
526
server/workspace/fs.go
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
// Package workspace provides filesystem operations for workspaces.
|
||||||
|
// It operates on the PVC filesystem and keeps the DB metadata index in sync
|
||||||
|
// via the WorkspaceStore.
|
||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Configuration ───────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultMaxBytes is the default workspace quota (500 MB).
|
||||||
|
DefaultMaxBytes int64 = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
// MaxSingleFileBytes is the max size for a single file write (100 MB).
|
||||||
|
MaxSingleFileBytes int64 = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
// filesSubdir is the subdirectory within a workspace root that holds user files.
|
||||||
|
// Keeps metadata (.workspace.json, future .git/) out of the user's namespace.
|
||||||
|
filesSubdir = "files"
|
||||||
|
|
||||||
|
// metadataFile is the workspace metadata file stored in the workspace root.
|
||||||
|
metadataFile = ".workspace.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── FS ──────────────────────────────────────
|
||||||
|
|
||||||
|
// FS provides filesystem operations for workspaces.
|
||||||
|
// All file paths are relative to the workspace's files/ subdirectory.
|
||||||
|
// The DB metadata index is updated on every mutating operation.
|
||||||
|
type FS struct {
|
||||||
|
basePath string // e.g. /data/storage/workspaces
|
||||||
|
store store.WorkspaceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFS creates a workspace filesystem manager.
|
||||||
|
func NewFS(basePath string, ws store.WorkspaceStore) *FS {
|
||||||
|
return &FS{basePath: basePath, store: ws}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init ensures the base workspaces directory exists.
|
||||||
|
func (fs *FS) Init() error {
|
||||||
|
return os.MkdirAll(fs.basePath, 0750)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path Helpers ────────────────────────────
|
||||||
|
|
||||||
|
// filesDir returns the absolute path to a workspace's file tree root.
|
||||||
|
func (fs *FS) filesDir(w *models.Workspace) string {
|
||||||
|
return filepath.Join(fs.basePath, w.ID, filesSubdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// absPath resolves a relative workspace path to an absolute filesystem path.
|
||||||
|
// Returns an error if the path escapes the workspace root (traversal attack).
|
||||||
|
func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) {
|
||||||
|
orig := strings.TrimSpace(relPath)
|
||||||
|
relPath = cleanPath(relPath)
|
||||||
|
if relPath == "" {
|
||||||
|
if orig != "" && orig != "." && orig != "./" {
|
||||||
|
// Non-empty input was sanitized to empty — traversal attempt
|
||||||
|
return "", fmt.Errorf("workspace: path traversal detected: %s", orig)
|
||||||
|
}
|
||||||
|
return fs.filesDir(w), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
abs := filepath.Join(root, relPath)
|
||||||
|
|
||||||
|
// Resolve symlinks and ensure we're still under root
|
||||||
|
absResolved, err := filepath.Abs(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("workspace: invalid path: %w", err)
|
||||||
|
}
|
||||||
|
rootResolved, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("workspace: invalid root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved {
|
||||||
|
return "", fmt.Errorf("workspace: path traversal detected: %s", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../
|
||||||
|
func cleanPath(p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
p = path.Clean(p)
|
||||||
|
p = strings.TrimPrefix(p, "/")
|
||||||
|
p = strings.TrimPrefix(p, "./")
|
||||||
|
|
||||||
|
// Reject paths that resolve outside root
|
||||||
|
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if p == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace Lifecycle ─────────────────────
|
||||||
|
|
||||||
|
// CreateDir creates the workspace directory structure on disk.
|
||||||
|
// Call after the DB row is created.
|
||||||
|
func (fs *FS) CreateDir(w *models.Workspace) error {
|
||||||
|
filesPath := fs.filesDir(w)
|
||||||
|
return os.MkdirAll(filesPath, 0750)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy removes the entire workspace directory from disk.
|
||||||
|
// Call after marking the workspace as "deleting" in DB.
|
||||||
|
func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error {
|
||||||
|
wsDir := filepath.Join(fs.basePath, w.ID)
|
||||||
|
|
||||||
|
// Remove all file index entries first
|
||||||
|
if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil {
|
||||||
|
log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.RemoveAll(wsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Operations ─────────────────────────
|
||||||
|
|
||||||
|
// ReadFile returns a reader for the file at relPath.
|
||||||
|
// Caller must close the returned ReadCloser.
|
||||||
|
func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) {
|
||||||
|
abs, err := fs.absPath(w, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath)
|
||||||
|
}
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return f, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile creates or overwrites a file at relPath.
|
||||||
|
// Creates parent directories as needed. Updates the DB metadata index.
|
||||||
|
func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error {
|
||||||
|
relPath = cleanPath(relPath)
|
||||||
|
if relPath == "" {
|
||||||
|
return fmt.Errorf("workspace: empty file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := fs.absPath(w, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject symlinks at destination
|
||||||
|
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
dir := filepath.Dir(abs)
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file + rename (atomic)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".ws-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("workspace: create temp: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer func() {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName) // cleanup on error; no-op after successful rename
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Write content and compute hash simultaneously
|
||||||
|
h := sha256.New()
|
||||||
|
tee := io.TeeReader(r, h)
|
||||||
|
n, err := io.Copy(tmp, tee)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("workspace: write %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("workspace: close temp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := os.Rename(tmpName, abs); err != nil {
|
||||||
|
return fmt.Errorf("workspace: rename %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB index
|
||||||
|
contentType := detectContentType(relPath, abs)
|
||||||
|
hash := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: relPath,
|
||||||
|
IsDirectory: false,
|
||||||
|
ContentType: contentType,
|
||||||
|
SizeBytes: n,
|
||||||
|
SHA256: hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile removes a file or empty directory at relPath.
|
||||||
|
func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error {
|
||||||
|
relPath = cleanPath(relPath)
|
||||||
|
if relPath == "" {
|
||||||
|
return fmt.Errorf("workspace: cannot delete workspace root")
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := fs.absPath(w, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Clean up DB index anyway
|
||||||
|
fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
if recursive {
|
||||||
|
if err := os.RemoveAll(abs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Remove all files under this prefix from the index
|
||||||
|
prefix := relPath
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix)
|
||||||
|
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||||
|
}
|
||||||
|
// Non-recursive: only remove empty dirs
|
||||||
|
if err := os.Remove(abs); err != nil {
|
||||||
|
return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.Remove(abs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mkdir creates a directory at relPath. Creates parents as needed.
|
||||||
|
func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error {
|
||||||
|
relPath = cleanPath(relPath)
|
||||||
|
if relPath == "" {
|
||||||
|
return nil // root always exists
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := fs.absPath(w, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(abs, 0750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: relPath,
|
||||||
|
IsDirectory: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file metadata without reading content.
|
||||||
|
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
|
||||||
|
relPath = cleanPath(relPath)
|
||||||
|
|
||||||
|
// Try DB first (fast)
|
||||||
|
f, err := fs.store.GetFile(ctx, w.ID, relPath)
|
||||||
|
if err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to filesystem
|
||||||
|
abs, err := fs.absPath(w, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workspace: file not found: %s", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: relPath,
|
||||||
|
IsDirectory: info.IsDir(),
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
ContentType: detectContentType(relPath, abs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir returns files at the given prefix.
|
||||||
|
func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||||
|
prefix = cleanPath(prefix)
|
||||||
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
return fs.store.ListFiles(ctx, w.ID, prefix, recursive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree returns all files in the workspace (recursive from root).
|
||||||
|
func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) {
|
||||||
|
return fs.store.ListFiles(ctx, w.ID, "", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reconcile ───────────────────────────────
|
||||||
|
|
||||||
|
// Reconcile walks the filesystem and syncs the DB index.
|
||||||
|
// Adds missing entries, removes stale entries, updates sizes/hashes.
|
||||||
|
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
|
||||||
|
root := fs.filesDir(w)
|
||||||
|
|
||||||
|
// Walk FS and collect all paths
|
||||||
|
fsPaths := make(map[string]os.FileInfo)
|
||||||
|
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Normalize to forward slashes
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
fsPaths[rel] = info
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all DB entries
|
||||||
|
dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles))
|
||||||
|
for i := range dbFiles {
|
||||||
|
dbPaths[dbFiles[i].Path] = &dbFiles[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add missing FS entries to DB
|
||||||
|
for relPath, info := range fsPaths {
|
||||||
|
if _, exists := dbPaths[relPath]; !exists {
|
||||||
|
abs := filepath.Join(root, relPath)
|
||||||
|
f := &models.WorkspaceFile{
|
||||||
|
WorkspaceID: w.ID,
|
||||||
|
Path: relPath,
|
||||||
|
IsDirectory: info.IsDir(),
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
ContentType: detectContentType(relPath, abs),
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
if hash, hashErr := hashFile(abs); hashErr == nil {
|
||||||
|
f.SHA256 = hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil {
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove DB entries not on FS
|
||||||
|
for dbPath := range dbPaths {
|
||||||
|
if _, exists := fsPaths[dbPath]; !exists {
|
||||||
|
if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil {
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
|
||||||
|
return added, removed, updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content Type Detection ──────────────────
|
||||||
|
|
||||||
|
// detectContentType determines the MIME type from extension first,
|
||||||
|
// then falls back to http.DetectContentType on the first 512 bytes.
|
||||||
|
func detectContentType(relPath, absPath string) string {
|
||||||
|
// Extension-based detection first (covers source code)
|
||||||
|
ext := strings.ToLower(filepath.Ext(relPath))
|
||||||
|
if ct := mimeByExtension(ext); ct != "" {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sniff the first 512 bytes
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, _ := f.Read(buf)
|
||||||
|
if n == 0 {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
return http.DetectContentType(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeByExtension returns MIME type for common extensions.
|
||||||
|
// mime.TypeByExtension misses many source code types.
|
||||||
|
func mimeByExtension(ext string) string {
|
||||||
|
// Source code extensions that mime.TypeByExtension doesn't know about
|
||||||
|
codeTypes := map[string]string{
|
||||||
|
".go": "text/x-go",
|
||||||
|
".rs": "text/x-rust",
|
||||||
|
".py": "text/x-python",
|
||||||
|
".rb": "text/x-ruby",
|
||||||
|
".java": "text/x-java",
|
||||||
|
".kt": "text/x-kotlin",
|
||||||
|
".swift": "text/x-swift",
|
||||||
|
".c": "text/x-c",
|
||||||
|
".cpp": "text/x-c++",
|
||||||
|
".h": "text/x-c",
|
||||||
|
".hpp": "text/x-c++",
|
||||||
|
".ts": "text/typescript",
|
||||||
|
".tsx": "text/typescript",
|
||||||
|
".jsx": "text/jsx",
|
||||||
|
".vue": "text/x-vue",
|
||||||
|
".svelte": "text/x-svelte",
|
||||||
|
".scala": "text/x-scala",
|
||||||
|
".pl": "text/x-perl",
|
||||||
|
".pm": "text/x-perl",
|
||||||
|
".lua": "text/x-lua",
|
||||||
|
".zig": "text/x-zig",
|
||||||
|
".nim": "text/x-nim",
|
||||||
|
".ex": "text/x-elixir",
|
||||||
|
".exs": "text/x-elixir",
|
||||||
|
".sh": "text/x-shellscript",
|
||||||
|
".bash": "text/x-shellscript",
|
||||||
|
".zsh": "text/x-shellscript",
|
||||||
|
".fish": "text/x-shellscript",
|
||||||
|
".sql": "text/x-sql",
|
||||||
|
".tf": "text/x-terraform",
|
||||||
|
".hcl": "text/x-hcl",
|
||||||
|
".proto": "text/x-protobuf",
|
||||||
|
".graphql": "text/x-graphql",
|
||||||
|
".toml": "application/toml",
|
||||||
|
".yaml": "text/yaml",
|
||||||
|
".yml": "text/yaml",
|
||||||
|
".dockerfile": "text/x-dockerfile",
|
||||||
|
".mod": "text/x-go-mod",
|
||||||
|
".sum": "text/x-go-sum",
|
||||||
|
".lock": "text/plain",
|
||||||
|
".gitignore": "text/plain",
|
||||||
|
".env": "text/plain",
|
||||||
|
".editorconfig": "text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct, ok := codeTypes[ext]; ok {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to standard library
|
||||||
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hashing ─────────────────────────────────
|
||||||
|
|
||||||
|
// hashFile computes SHA256 of a file.
|
||||||
|
func hashFile(absPath string) (string, error) {
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
388
server/workspace/fs_test.go
Normal file
388
server/workspace/fs_test.go
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Path Cleaning Tests ─────────────────────
|
||||||
|
|
||||||
|
func TestCleanPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"src/main.go", "src/main.go"},
|
||||||
|
{"/src/main.go", "src/main.go"},
|
||||||
|
{"./src/main.go", "src/main.go"},
|
||||||
|
{"src/../etc/passwd", "etc/passwd"},
|
||||||
|
{"../etc/passwd", ""},
|
||||||
|
{"../..", ""},
|
||||||
|
{"..", ""},
|
||||||
|
{".", ""},
|
||||||
|
{"", ""},
|
||||||
|
{" src/main.go ", "src/main.go"},
|
||||||
|
{"src/./main.go", "src/main.go"},
|
||||||
|
{".gitignore", ".gitignore"},
|
||||||
|
{".env", ".env"},
|
||||||
|
{"src/.hidden/file.go", "src/.hidden/file.go"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := cleanPath(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsPathTraversal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
fs := &FS{basePath: tmpDir}
|
||||||
|
|
||||||
|
w := &models.Workspace{
|
||||||
|
BaseModel: models.BaseModel{ID: "test-workspace"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the files dir so absPath has a real root to resolve against
|
||||||
|
filesDir := filepath.Join(tmpDir, w.ID, filesSubdir)
|
||||||
|
os.MkdirAll(filesDir, 0750)
|
||||||
|
|
||||||
|
// Valid paths should work
|
||||||
|
validPaths := []string{
|
||||||
|
"src/main.go",
|
||||||
|
"README.md",
|
||||||
|
".gitignore",
|
||||||
|
"deeply/nested/path/file.txt",
|
||||||
|
}
|
||||||
|
for _, p := range validPaths {
|
||||||
|
abs, err := fs.absPath(w, p)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("absPath(%q) unexpected error: %v", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(abs, filesDir) {
|
||||||
|
t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traversal attempts should fail
|
||||||
|
traversalPaths := []string{
|
||||||
|
"../../../etc/passwd",
|
||||||
|
"src/../../etc/passwd",
|
||||||
|
}
|
||||||
|
for _, p := range traversalPaths {
|
||||||
|
_, err := fs.absPath(w, p)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("absPath(%q) should have returned error for traversal", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content Type Detection Tests ────────────
|
||||||
|
|
||||||
|
func TestMimeByExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
ext string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{".go", "text/x-go"},
|
||||||
|
{".py", "text/x-python"},
|
||||||
|
{".rs", "text/x-rust"},
|
||||||
|
{".ts", "text/typescript"},
|
||||||
|
{".sh", "text/x-shellscript"},
|
||||||
|
{".sql", "text/x-sql"},
|
||||||
|
{".yaml", "text/yaml"},
|
||||||
|
{".yml", "text/yaml"},
|
||||||
|
{".toml", "application/toml"},
|
||||||
|
{".pl", "text/x-perl"},
|
||||||
|
{".pm", "text/x-perl"},
|
||||||
|
{".lua", "text/x-lua"},
|
||||||
|
{".dockerfile", "text/x-dockerfile"},
|
||||||
|
{".unknown", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.ext, func(t *testing.T) {
|
||||||
|
got := mimeByExtension(tt.ext)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unsafe Path Tests ───────────────────────
|
||||||
|
|
||||||
|
func TestIsUnsafePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
unsafe bool
|
||||||
|
}{
|
||||||
|
{"src/main.go", false},
|
||||||
|
{".gitignore", false},
|
||||||
|
{".env", false},
|
||||||
|
{"../etc/passwd", true},
|
||||||
|
{"/etc/passwd", true},
|
||||||
|
{"src/../../../etc/passwd", true},
|
||||||
|
{"\\windows\\system32", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
got := isUnsafePath(tt.path)
|
||||||
|
if got != tt.unsafe {
|
||||||
|
t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Common Prefix Detection Tests ───────────
|
||||||
|
|
||||||
|
func TestDetectCommonPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
names []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "common prefix",
|
||||||
|
names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"},
|
||||||
|
want: "project/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no common prefix",
|
||||||
|
names: []string{"src/a.go", "lib/b.go"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file at root",
|
||||||
|
names: []string{"project/src/a.go", "Makefile"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
names: []string{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single dir",
|
||||||
|
names: []string{"myproject/file.txt"},
|
||||||
|
want: "myproject/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := detectCommonPrefix(tt.names)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration: Write + Read Round-Trip ────
|
||||||
|
|
||||||
|
type mockWorkspaceStore struct {
|
||||||
|
files map[string]*models.WorkspaceFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockStore() *mockWorkspaceStore {
|
||||||
|
return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil }
|
||||||
|
func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil }
|
||||||
|
func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||||
|
key := f.WorkspaceID + ":" + f.Path
|
||||||
|
m.files[key] = f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||||
|
delete(m.files, workspaceID+":"+path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||||
|
for k := range m.files {
|
||||||
|
if strings.HasPrefix(k, workspaceID+":"+prefix) {
|
||||||
|
delete(m.files, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||||
|
f, ok := m.files[workspaceID+":"+path]
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||||
|
var out []models.WorkspaceFile
|
||||||
|
for _, f := range m.files {
|
||||||
|
if f.WorkspaceID == workspaceID {
|
||||||
|
if prefix == "" || strings.HasPrefix(f.Path, prefix) {
|
||||||
|
out = append(out, *f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||||
|
for k, f := range m.files {
|
||||||
|
if f.WorkspaceID == workspaceID {
|
||||||
|
delete(m.files, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteReadRoundTrip(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mock := newMockStore()
|
||||||
|
wfs := NewFS(tmpDir, mock)
|
||||||
|
|
||||||
|
w := &models.Workspace{
|
||||||
|
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := wfs.CreateDir(w); err != nil {
|
||||||
|
t.Fatalf("CreateDir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
content := "package main\n\nfunc main() {}\n"
|
||||||
|
|
||||||
|
// Write
|
||||||
|
err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DB index was updated
|
||||||
|
f, ok := mock.files["test-ws:src/main.go"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected file in mock store index")
|
||||||
|
}
|
||||||
|
if f.ContentType != "text/x-go" {
|
||||||
|
t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go")
|
||||||
|
}
|
||||||
|
if f.SizeBytes != int64(len(content)) {
|
||||||
|
t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content))
|
||||||
|
}
|
||||||
|
if f.SHA256 == "" {
|
||||||
|
t.Error("expected non-empty sha256")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back
|
||||||
|
rc, size, err := wfs.ReadFile(ctx, w, "src/main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
if size != int64(len(content)) {
|
||||||
|
t.Errorf("read size = %d, want %d", size, len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := rc.Read(buf); err != nil {
|
||||||
|
t.Fatalf("Read: %v", err)
|
||||||
|
}
|
||||||
|
if string(buf) != content {
|
||||||
|
t.Errorf("content = %q, want %q", string(buf), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mock := newMockStore()
|
||||||
|
wfs := NewFS(tmpDir, mock)
|
||||||
|
|
||||||
|
w := &models.Workspace{
|
||||||
|
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||||
|
}
|
||||||
|
wfs.CreateDir(w)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
content := "hello"
|
||||||
|
wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content)))
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
err := wfs.DeleteFile(ctx, w, "test.txt", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify removed from index
|
||||||
|
if _, ok := mock.files["test-ws:test.txt"]; ok {
|
||||||
|
t.Error("expected file removed from index")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify removed from filesystem
|
||||||
|
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt")
|
||||||
|
if _, err := os.Stat(abs); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected file removed from filesystem")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMkdir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mock := newMockStore()
|
||||||
|
wfs := NewFS(tmpDir, mock)
|
||||||
|
|
||||||
|
w := &models.Workspace{
|
||||||
|
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||||
|
}
|
||||||
|
wfs.CreateDir(w)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := wfs.Mkdir(ctx, w, "src/pkg/handlers")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify on disk
|
||||||
|
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers")
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Error("expected directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify in index
|
||||||
|
f, ok := mock.files["test-ws:src/pkg/handlers"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected dir in mock store")
|
||||||
|
}
|
||||||
|
if !f.IsDirectory {
|
||||||
|
t.Error("expected is_directory = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user