From 96a4f16bc5d45071cd535b821a1ece35a2c0020a Mon Sep 17 00:00:00 2001 From: gobha Date: Tue, 24 Mar 2026 16:50:00 +0000 Subject: [PATCH] Changeset 0.37.17 (#229) Co-authored-by: gobha Co-committed-by: gobha --- CHANGELOG.md | 82 +++ docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md | 532 ++++++++++++++++++ docs/ICD/projects.md | 73 ++- docs/ROADMAP.md | 56 +- packages/sdk-test-runner/css/main.css | 222 ++++++++ packages/sdk-test-runner/js/domains/admin.js | 153 +++++ .../sdk-test-runner/js/domains/channels.js | 158 ++++++ .../sdk-test-runner/js/domains/knowledge.js | 82 +++ packages/sdk-test-runner/js/domains/misc.js | 196 +++++++ packages/sdk-test-runner/js/domains/notes.js | 106 ++++ .../sdk-test-runner/js/domains/personas.js | 66 +++ .../sdk-test-runner/js/domains/projects.js | 134 +++++ packages/sdk-test-runner/js/domains/tasks.js | 107 ++++ .../sdk-test-runner/js/domains/workflows.js | 93 +++ .../sdk-test-runner/js/domains/workspaces.js | 83 +++ packages/sdk-test-runner/js/fixtures.js | 239 ++++++++ packages/sdk-test-runner/js/framework.js | 357 ++++++++++++ packages/sdk-test-runner/js/main.js | 107 ++++ packages/sdk-test-runner/js/shapes.js | 112 ++++ packages/sdk-test-runner/js/ui.js | 262 +++++++++ packages/sdk-test-runner/manifest.json | 9 + server/handlers/files.go | 449 +++++++++++++-- server/main.go | 20 +- server/static/openapi.yaml | 194 ++++++- src/css/sw-projects-surface.css | 122 ++++ src/js/sw/sdk/api-domains.js | 27 +- src/js/sw/surfaces/projects/detail.js | 173 +++++- 27 files changed, 4107 insertions(+), 107 deletions(-) create mode 100644 docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md create mode 100644 packages/sdk-test-runner/css/main.css create mode 100644 packages/sdk-test-runner/js/domains/admin.js create mode 100644 packages/sdk-test-runner/js/domains/channels.js create mode 100644 packages/sdk-test-runner/js/domains/knowledge.js create mode 100644 packages/sdk-test-runner/js/domains/misc.js create mode 100644 packages/sdk-test-runner/js/domains/notes.js create mode 100644 packages/sdk-test-runner/js/domains/personas.js create mode 100644 packages/sdk-test-runner/js/domains/projects.js create mode 100644 packages/sdk-test-runner/js/domains/tasks.js create mode 100644 packages/sdk-test-runner/js/domains/workflows.js create mode 100644 packages/sdk-test-runner/js/domains/workspaces.js create mode 100644 packages/sdk-test-runner/js/fixtures.js create mode 100644 packages/sdk-test-runner/js/framework.js create mode 100644 packages/sdk-test-runner/js/main.js create mode 100644 packages/sdk-test-runner/js/shapes.js create mode 100644 packages/sdk-test-runner/js/ui.js create mode 100644 packages/sdk-test-runner/manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4195dbf..7175214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,87 @@ # Changelog +## [0.37.17.0] — 2026-03-24 + +### Summary + +Workspaces — workspace-first file storage for projects. Project files route +through the workspace FS subsystem (auto-created on first upload) instead of +the flat objStore path. Tree browser UI with nested folders, archive support +(zip/tar.gz upload and download), file actions (download, delete, mkdir), and +upload improvements (progress counter, archive detection, quota errors). + +Architecture decision: `files` table + objStore = ephemeral message attachments; +Workspace FS = persistent file collections (tree, dirs, archive, quota, indexing). +"Save to workspace" bridge from chat files deferred to v0.37.18. + +Also: extension track continuation scheduled as v0.39.x in roadmap, two new +design docs committed (multi-file Starlark, extension connections & libraries). + +### Added + +- **Workspace-backed project files:** `ensureProjectWorkspace()` auto-creates a + workspace (`owner_type=project`) on first file upload, sets `project.workspace_id`. + All project file operations route through workspace FS instead of objStore. + (`server/handlers/files.go`) +- **File download:** `GET /projects/:id/files/download?path=` streams raw file + content with `Content-Disposition: attachment`. (`server/handlers/files.go`) +- **File delete:** `DELETE /projects/:id/files?path=` with optional `recursive` + param for directories. (`server/handlers/files.go`) +- **Create directory:** `POST /projects/:id/files/mkdir` accepts path via JSON + body or query param. (`server/handlers/files.go`) +- **Archive upload:** `POST /projects/:id/archive/upload` extracts .zip/.tar.gz + into project workspace. Returns `files_extracted` count. + (`server/handlers/files.go`) +- **Archive download:** `GET /projects/:id/archive/download?format=zip` bundles + all project files into a zip/tar.gz. (`server/handlers/files.go`) +- **File browser UI:** Tree browser with nested folders (expand/collapse), + type icons, size display, on-hover delete button. Replaces the flat file list + from v0.37.16. (`src/js/sw/surfaces/projects/detail.js`) +- **File action bar:** "New folder" and "Download all" buttons above the file + tree when files exist. (`detail.js`) +- **Upload improvements:** Progress counter ("Uploading 3 of 7…"), archive + detection (`.zip`/`.tar.gz` → confirm extract dialog), specific error + messages (413 = quota exceeded). (`detail.js`) +- **SDK methods:** `deleteFile`, `mkdir`, `uploadArchive` added to + `sw.api.projects` domain. `files()` and `uploadFile()` updated to support + path param. (`src/js/sw/sdk/api-domains.js`) +- **File browser CSS:** 120 lines — file-row, file-chevron, file-icon, + file-name, file-meta, file-delete, dropzone, file-actions-bar. + (`src/css/sw-projects-surface.css`) +- **DESIGN-EXT-CONNECTIONS-LIBRARIES.md:** Extension connections (scoped + credentials) and library packages (shared code + data via `lib.load()`). + Three implementation phases. (`docs/`) +- **Extension track v0.39.x:** Roadmap updated — v0.39.0 multi-file Starlark, + v0.39.1 connections, v0.39.2 libraries, v0.39.3 full composition. + (`docs/ROADMAP.md`) + +### Changed + +- **Project file list response:** Now returns `WorkspaceFile` objects (with + `path`, `is_directory`, `content_type`, `size_bytes`) instead of `File` model + objects. `GET /projects/:id/files` supports `?path=` and `?recursive=` params. +- **FileHandler constructor:** Gains `SetWorkspaceFS()` method for workspace + injection. Project file routes use dedicated `projFileH` instance with wfs + attached. (`server/main.go`) +- **ICD updated:** `docs/ICD/projects.md` — expanded Project Files section with + all 7 endpoints, request/response schemas, error codes. +- **OpenAPI updated:** `server/static/openapi.yaml` — 5 new paths for project + file operations (download, delete, mkdir, archive upload/download). + +### Known Limitations + +- **User default workspace:** Not yet implemented. Personal "My Files" deferred + to v0.37.18. +- **Tool output persistence:** `origin=tool_output` auto-save for LLM-generated + artifacts deferred to v0.37.18. +- **Save to workspace bridge:** "Save" action to promote ephemeral chat files + to workspace storage deferred to v0.37.18. +- **Workspace unique constraint:** Race guard on concurrent workspace creation + relies on DB error handling (re-read on duplicate key). Migration 014 for + explicit `UNIQUE(owner_type, owner_id)` not yet added. + +--- + ## [0.37.16.0] — 2026-03-24 ### Summary diff --git a/docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md b/docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md new file mode 100644 index 0000000..9078543 --- /dev/null +++ b/docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md @@ -0,0 +1,532 @@ +# DESIGN — Extension Connections & Library Packages + +Two platform primitives. Extensions that talk to external services +need scoped credential management. Extensions that share logic and +data need a dependency mechanism with clean boundaries. + +--- + +## Part 1: Extension Connections + +### Problem + +Extensions integrating with external services need credentials and +endpoint config. The current `settings` mechanism is flat key-value. +It breaks when a user works with two instances of the same service, +when teams share bot tokens alongside personal tokens, or when +multiple extensions need the same credentials. + +### Design + +A **connection** concept. Same scope/resolution pattern as LLM +providers — scoped CRUD, resolution chain — but generic, owned by +package manifests, and sharable across packages. + +#### Manifest Declaration + +```json +{ + "id": "git-board", + "connections": [ + { + "type": "gitea", + "label": "Gitea Instance", + "fields": { + "base_url": {"type": "url", "required": true, "label": "Server URL"}, + "api_token": {"type": "secret", "required": true, "label": "API Token"}, + "org": {"type": "string", "required": false, "label": "Default Org"} + }, + "scopes": ["global", "team", "personal"] + }, + { + "type": "github", + "label": "GitHub", + "fields": { + "api_token": {"type": "secret", "required": true, "label": "Personal Access Token"}, + "org": {"type": "string", "required": false, "label": "Default Org"} + }, + "scopes": ["global", "personal"] + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `type` | Connection type identifier. Shared by name — if two packages declare `type: "gitea"`, users configure once and both packages resolve it. Prefix to namespace: `"git-board:internal"`. | +| `label` | Human-readable name for the UI. | +| `fields` | Config form schema. Types: `string`, `url`, `secret`, `number`, `boolean`, `select`. | +| `scopes` | Allowed scopes. Subset of `["global", "team", "personal"]`. | + +#### Storage + +```sql +CREATE TABLE ext_connections ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + package_id TEXT NOT NULL, -- declaring package (UI attribution) + scope TEXT NOT NULL, -- global | team | personal + owner_id TEXT NOT NULL, -- '' for global, team_id, or user_id + name TEXT NOT NULL, -- user label: "Work Gitea" + config TEXT NOT NULL, -- JSON, secrets encrypted at rest + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + UNIQUE(type, scope, owner_id, name) +); +``` + +Secrets use the same vault pattern as `provider_configs.api_key`. + +#### Resolution Chain + +Personal → Team → Global. Same walk as provider resolution. + +```python +conn = connections.get("gitea") # scope chain +conn = connections.get("gitea", name="Work Gitea") # explicit +all = connections.list("gitea") # all available +``` + +#### API Endpoints + +``` +# Admin (global) +POST/GET/PUT/DELETE /api/v1/admin/connections[/:id] + +# Team +POST/GET/PUT/DELETE /api/v1/teams/:teamId/connections[/:id] + +# Personal +POST/GET/PUT/DELETE /api/v1/connections[/:id] + +# Resolution +GET /api/v1/connections/resolve?type=gitea&name=Work+Gitea +``` + +#### UI + +Three surfaces matching the provider config pattern: +- **Admin → Connections** — global. Type picker from installed packages. +- **Team Admin → Connections** — team-scoped. +- **Settings → Connections** — personal. + +--- + +## Part 2: Library Packages + +### Problem + +Extensions duplicate everything. Two packages talking to Gitea write +the same API client, the same auth logic, the same pagination. If +they cache data, each creates its own tables with the same schema. +There's no mechanism for sharing code, data access, or connection +types across packages. + +### Core Principle + +**Libraries are services, not shared databases.** + +A library owns its data privately. Consumers access it through +exported Starlark functions or REST endpoints — never by querying +the library's tables directly. The library controls validation, +access patterns, schema evolution, and data integrity. Consumers +are decoupled from the physical storage. + +Same principle as microservices vs shared databases: share the +schema, share the coupling. Share the API, share the contract. + +### Design + +A new package type: `library`. Libraries can provide: + +- **Starlark functions** — server-side consumers call via `lib.load()` +- **REST endpoints** — browser-side consumers call via HTTP +- **Connection types** — inherited by consumers +- **Private DB tables** — optional, never exposed directly +- Any combination of the above + +Libraries do **not** have: surfaces, LLM tools, or pipe filters. + +#### Package Type Taxonomy + +| Type | Surface | Starlark exports | REST endpoints | LLM tools | Private DB | Depends on libs | +|------|---------|-----------------|----------------|-----------|------------|-----------------| +| `surface` | ✓ | — | — | — | — | via REST | +| `extension` | — | — | — | ✓ | ✓ | ✓ | +| `library` | — | ✓ | ✓ | — | ✓ | ✓ | +| `full` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | + +#### Library Manifest + +```json +{ + "id": "gitea-client", + "title": "Gitea API Client", + "type": "library", + "tier": "starlark", + "version": "1.0.0", + "description": "Shared Gitea client — connections, API, and data caching.", + + "permissions": ["api.http", "db.read", "db.write"], + + "exports": [ + "get_repos", "sync_repos", + "get_issues", "get_issue", "create_issue", "update_issue", + "get_prs", "get_ci_status", + "search_cached_issues" + ], + + "api_routes": [ + {"method": "GET", "path": "/repos"}, + {"method": "GET", "path": "/issues"}, + {"method": "GET", "path": "/issues/*"}, + {"method": "POST", "path": "/issues"}, + {"method": "GET", "path": "/prs"}, + {"method": "GET", "path": "/ci/*"}, + {"method": "POST", "path": "/sync"} + ], + + "connections": [ + { + "type": "gitea", + "label": "Gitea Instance", + "fields": { + "base_url": {"type": "url", "required": true}, + "api_token": {"type": "secret", "required": true}, + "org": {"type": "string", "required": false} + }, + "scopes": ["global", "team", "personal"] + } + ], + + "db_tables": [ + { + "name": "repos", + "columns": { + "connection_id": "text", "full_name": "text", + "owner": "text", "name": "text", + "description": "text", "html_url": "text", + "open_issues": "integer", "synced_at": "text" + } + }, + { + "name": "issues", + "columns": { + "connection_id": "text", "repo_full_name": "text", + "number": "integer", "title": "text", "state": "text", + "labels": "text", "assignee": "text", "body": "text", + "created_at": "text", "synced_at": "text" + } + } + ], + + "schema_version": 1 +} +``` + +Physical tables: `ext_gitea_client_repos`, `ext_gitea_client_issues`. +Private to the library. The db module remains package-scoped. +`physicalTable()` is unchanged — no cross-package table access. + +#### script.star (library) + +Two entry points. `exports` for Starlark consumers. `on_request` +for REST consumers. Both use the same internal logic. The DB is an +implementation detail. + +```python +# ═══════════════════════════════════════════ +# Exported functions (Starlark consumers) +# ═══════════════════════════════════════════ + +def get_repos(conn): + """Fetch repos live from Gitea API.""" + url = conn["base_url"] + "/api/v1/repos/search?limit=50" + resp = http.get(url=url, headers=_auth(conn)) + if int(resp["status"]) >= 400: + return None + return json.decode(resp["body"]) + +def sync_repos(conn): + """Fetch repos and cache locally. Returns count.""" + repos = get_repos(conn) + if repos == None: + return 0 + conn_id = conn.get("id", "") + for row in db.query("repos", filters={"connection_id": conn_id}): + db.delete("repos", row["id"]) + for r in repos: + db.insert("repos", { + "connection_id": conn_id, + "full_name": r.get("full_name", ""), + "owner": r.get("owner", {}).get("login", ""), + "name": r.get("name", ""), + "description": r.get("description", ""), + "html_url": r.get("html_url", ""), + "open_issues": int(r.get("open_issues_count", 0)), + "synced_at": "", + }) + return len(repos) + +def get_issues(conn, owner, repo, state="open"): + """Fetch issues live from Gitea API.""" + url = (conn["base_url"] + "/api/v1/repos/" + owner + "/" + repo + + "/issues?state=" + state + "&limit=50&type=issues") + resp = http.get(url=url, headers=_auth(conn)) + if int(resp["status"]) >= 400: + return None + return json.decode(resp["body"]) + +def search_cached_issues(conn_id, repo_full_name, state=None): + """Query locally cached issues. No API call.""" + filters = {"connection_id": conn_id, "repo_full_name": repo_full_name} + if state: + filters["state"] = state + return db.query("issues", filters=filters, order="-synced_at", limit=200) + +def create_issue(conn, owner, repo, title, body="", labels=None): + """Create issue via Gitea API.""" + payload = {"title": title} + if body: + payload["body"] = body + if labels: + payload["labels"] = labels + url = conn["base_url"] + "/api/v1/repos/" + owner + "/" + repo + "/issues" + resp = http.post(url=url, body=json.encode(payload), + headers=_auth_json(conn)) + if int(resp["status"]) >= 400: + return None + return json.decode(resp["body"]) + +# ... update_issue, get_prs, get_ci_status same pattern ... + + +# ═══════════════════════════════════════════ +# REST endpoints (browser / external) +# ═══════════════════════════════════════════ + +def on_request(req): + path = req["path"] + method = req["method"] + conn = connections.get("gitea") + if not conn: + return _resp(400, {"error": "no gitea connection configured"}) + + if method == "GET" and path == "/repos": + return _resp(200, {"data": get_repos(conn) or []}) + + if method == "POST" and path == "/sync": + return _resp(200, {"synced": sync_repos(conn)}) + + if method == "GET" and path == "/issues": + q = req.get("query", {}) + owner = _str(q.get("owner", "")) + repo = _str(q.get("repo", "")) + if owner and repo: + return _resp(200, {"data": get_issues(conn, owner, repo) or []}) + return _resp(200, {"data": db.query("issues", order="-synced_at", limit=200)}) + + return _resp(404, {"error": "not found"}) + +def _auth(conn): + return {"Authorization": "token " + conn.get("api_token", "")} + +def _auth_json(conn): + return {"Authorization": "token " + conn.get("api_token", ""), + "Content-Type": "application/json"} + +def _resp(status, data): + return {"status": status, "body": json.encode(data), + "headers": {"Content-Type": "application/json"}} + +def _str(v): + return str(v) if v != None else "" +``` + +The DB calls are all in the library's script. Consumers never see +the physical tables. + +#### Consumer: Starlark Path + +```json +{ + "id": "ci-monitor", + "type": "extension", + "tier": "starlark", + "dependencies": {"gitea-client": ">=1.0.0"}, + "tools": [{"name": "check_ci", "description": "Check CI status", + "parameters": {"owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "ref": {"type": "string", "required": true}}}] +} +``` + +```python +gitea = lib.load("gitea-client") + +def on_tool_call(tool_name, params): + conn = connections.get("gitea") + if tool_name == "check_ci": + return gitea.get_ci_status(conn, params["owner"], + params["repo"], params["ref"]) + return {"error": "unknown tool"} +``` + +No `db_tables`. No `connections` declaration. No `permissions` for +db access. The consumer calls functions. + +#### Consumer: REST Path (browser-only) + +A pure `type: "surface"` package calls the library's REST endpoints: + +```js +// git-board/js/main.js — no Starlark, no lib.load +var repos = await fetch(BASE + '/s/gitea-client/api/repos', { + headers: { 'Authorization': 'Bearer ' + token } +}).then(r => r.json()); + +await fetch(BASE + '/s/gitea-client/api/sync', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token } +}); +``` + +This means `git-board` can be `type: "surface"` (browser-only). +Tools come from the library. Data comes from the library's REST API. +Connections come from the library. The surface just renders. + +#### Permission Model + +Libraries have their **own** permissions, granted by the admin: + +- `gitea-client` gets `api.http` + `db.read` + `db.write` +- `ci-monitor` gets nothing beyond its own tools + +When `ci-monitor` calls `gitea.get_ci_status(conn, ...)`, the +library function runs with the **library's** permissions. The +library can make HTTP calls and write to its own tables regardless +of what the consumer has. + +The admin trusts the library (by granting permissions). The consumer +trusts the library (by declaring a dependency). The library trusts +nobody — it validates inputs and controls its own data. + +#### `lib.load()` Mechanics + +1. Check `ext_dependencies`: caller must declare dependency. +2. Load library's `script.star`. +3. Build library's module set using **library's own** permissions. +4. Execute in sandboxed context. +5. Extract globals listed in `exports`. +6. Return as frozen `starlarkstruct.Struct`. +7. Cache per sandbox invocation. + +The library's `db` module resolves to `ext_gitea_client_*`. +The consumer's `db` module (if any) resolves to `ext_ci_monitor_*`. +No cross-contamination. `physicalTable()` unchanged. + +#### Dependencies + +```sql +CREATE TABLE ext_dependencies ( + consumer_id TEXT NOT NULL, + library_id TEXT NOT NULL, + version_spec TEXT NOT NULL, + resolved_ver TEXT NOT NULL, + PRIMARY KEY (consumer_id, library_id), + FOREIGN KEY (consumer_id) REFERENCES package_registry(id) + ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES package_registry(id) + ON DELETE RESTRICT +); +``` + +`ON DELETE RESTRICT`: cannot uninstall a library with consumers. +Libraries can depend on libraries. Circular deps rejected at install. + +#### Schema Migrations + +Libraries own their migrations. Consumers are unaffected. If a +migration changes internal tables, the library's exported functions +adapt. Private schema is NOT part of the API surface. + +#### Breaking Changes + +**API surface = exports + REST endpoints + connection types.** + +- Remove/rename an export or endpoint → major version bump. +- Change a function's return shape → major version bump. +- Change connection type fields → major version bump. +- Add new exports, endpoints, fields → minor version bump. +- Internal DB changes, bug fixes → patch version bump. + +Enforcement deferred. Social contract for now. + +--- + +## The Full Stack + +``` +┌──────────────────────────────────────────────┐ +│ gitea-client (library) │ +│ │ +│ connections: gitea │ +│ exports: get_repos, sync_repos, ... │ +│ REST: /repos, /issues, /ci/*, /sync │ +│ private DB: repos, issues (untouchable) │ +│ │ +│ permissions: api.http, db.read, db.write │ +└────────┬────────────────┬────────────────┬───┘ + │ │ │ + lib.load() lib.load() fetch() + │ │ │ + ┌─────┴──────┐ ┌──────┴───────┐ ┌────┴──────┐ + │ ci-monitor │ │ code-review │ │ git-board │ + │ extension │ │ extension │ │ surface │ + │ (starlark) │ │ (starlark) │ │ (browser) │ + └────────────┘ └──────────────┘ └───────────┘ +``` + +One library. N consumers. Zero schema coupling. + +--- + +## Implementation Phases + +### Phase 1: Extension Connections + +Prerequisite: none. + +- `ext_connections` table + store + handlers + UI +- `connections` Starlark module +- Three management surfaces (admin / team / personal) + +### Phase 2: Library Packages + +Prerequisite: none. Independent of Phase 1. + +- `library` package type in installer +- `ext_dependencies` table +- `lib.load()` with per-library permission context +- Library `api_routes` (already works) +- Dependency resolution + uninstall protection +- Admin UI: dependency tree + +### Phase 3: Full Composition + +Prerequisite: Phase 1 + 2. + +- Libraries declare connection types for consumers +- Build `gitea-client` reference library +- Migrate `git-board` to library-backed surface + +--- + +## Not Covered + +- **OAuth flows.** Static credentials only. +- **Package registry / marketplace.** `.pkg` archives, no central registry. +- **Runtime version pinning.** Installed version wins. +- **Cross-package events.** Functions and REST, not event subscriptions. +- **Library UI.** No surface. Config via package settings + connections. diff --git a/docs/ICD/projects.md b/docs/ICD/projects.md index 9471890..bdb4bfc 100644 --- a/docs/ICD/projects.md +++ b/docs/ICD/projects.md @@ -223,15 +223,80 @@ Insert uses ON CONFLICT DO NOTHING (idempotent). --- -## Project Files +## Project Files (v0.37.17 — workspace-backed) + +Project files are stored in the project's workspace (auto-created on +first upload). The response uses `"files"` key, not `"data"`. Files +are `WorkspaceFile` objects with tree structure (directories + files). + +### List Files ``` GET /projects/:id/files → { "files": [...], "count": N } -POST /projects/:id/files ← multipart/form-data + ?path=/docs&recursive=true ``` -Project-level file uploads (distinct from workspace files and channel -attachments). Note: `GET` uses `"files"` key, not `"data"`. +| Param | Default | Notes | +|-------|---------|-------| +| `path` | `""` | Directory to list (empty = root) | +| `recursive` | `true` | Include subdirectories | + +### Upload File + +``` +POST /projects/:id/files ← multipart/form-data + ?path=/docs +``` + +Multipart form field: `file`. Optional `path` query param to upload +into a subdirectory. Auto-creates the project workspace on first upload. +Returns `201` with `{ path, filename, content_type, size_bytes }`. + +**Errors:** `413` if file exceeds size limit or workspace quota. + +### Download File + +``` +GET /projects/:id/files/download?path=/docs/readme.md +``` + +Streams raw file content with `Content-Disposition: attachment`. + +### Delete File + +``` +DELETE /projects/:id/files?path=/docs/readme.md + &recursive=false +``` + +`recursive=true` to delete directories with contents. + +### Create Directory + +``` +POST /projects/:id/files/mkdir ← { "path": "/docs/images" } +``` + +Path also accepted via `?path=` query param. Returns `201`. + +### Upload Archive + +``` +POST /projects/:id/archive/upload ← multipart/form-data +``` + +Multipart field: `file`. Accepts `.zip`, `.tar.gz`, `.tgz`. +Extracts contents into the workspace root. +Returns `{ ok: true, files_extracted: N }`. + +### Download Archive + +``` +GET /projects/:id/archive/download?format=zip +``` + +Bundles all project files into a zip (or `tar.gz`). +Streams with `Content-Disposition: attachment`. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1f54e2f..0324110 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,10 +46,18 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ │ │ │ v0.37.1 Perm Audit ✅ │ │ v0.37.2 Primitives ✅ │ - │ v0.37.3 SDK │ - │ v0.37.4 Shell │ - │ v0.37.5–14 Surfaces ✅ │ - │ v0.37.15–18 Surfaces │ + │ v0.37.3 SDK ✅ │ + │ v0.37.4 Shell ✅ │ + │ v0.37.5–16 Surfaces ✅ │ + │ v0.37.17–19 Surfaces │ + │ │ │ + │ v0.38.x Workflow │ + │ Product Maturity │ + │ │ │ + v0.39.0 │ │ + v0.39.1 │ │ + v0.39.2 │ │ + v0.39.3 │ │ │ │ │ ══════╪════════════╪═════════════════╪══════ │ MVP v0.50.0 │ @@ -185,7 +193,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. | v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes | | v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue | | v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link | -| v0.37.17 | Workspaces | File storage layer, archive upload/download (zip/tar), project file integration | +| v0.37.17 | Workspaces | Workspace-first project file storage, tree browser UI, archive upload/download | | v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) | | v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) | @@ -213,21 +221,28 @@ storage deferred to v0.37.17. **v0.37.17 — Workspaces (File Storage):** -Workspace storage layer for project files. Archive (zip, tar, etc.) -upload/download is **required for MVP** — projects need document attachment. +Workspace-first file management for projects. Architecture: `files` table + +objStore = ephemeral message attachments; Workspace FS = persistent file +collections (tree, dirs, archive, quota, indexing). Bridge ("Save to workspace") +deferred to v0.37.18. + Schema and model FK already exist (`workspaces` table, `Project.WorkspaceID`). +Workspace backend fully implemented (FS layer, handlers, store, SDK). This +version bridges projects to workspaces and builds the file browser UI. Git integration (clone, pull, branch tracking) is optional/post-MVP. -- [ ] Workspace CRUD endpoints wiring (create-on-demand for projects) -- [ ] File upload backend: disk storage with workspace root path -- [ ] Archive upload: zip/tar extraction + individual file indexing -- [ ] File download: single file + archive download (zip bundle) -- [ ] File browser UI in project sidebar (list, preview, delete) -- [ ] Storage quota enforcement (`max_bytes` on workspace) +- [x] Auto-create workspace for project on first file upload +- [x] Route project file uploads through workspace FS +- [x] File browser UI in project sidebar (tree, folders, download, delete) +- [x] Archive upload: zip/tar extraction into workspace +- [x] Archive download: zip bundle of project files +- [x] Storage quota enforcement (`max_bytes` on workspace) **v0.37.18 — Debug + Model Surface:** Debug modal Preact rebuild, model configuration UI, provider diagnostics. +Also: user default workspace, `origin=tool_output` auto-save in completion +handler, "Save to workspace" bridge action on chat files/artifacts. **v0.37.19 — Tag ("UI Complete"):** @@ -254,6 +269,21 @@ Visual workflow builder series. Bridges .37 plumbing to MVP gate --- +## v0.39.x — Extension Track Continuation + +Resumes the extension track (v0.29–v0.31 ✅) with three new platform +primitives: multi-file Starlark packages, extension connections, and library +packages. See design docs for full specifications. + +| Version | Summary | Design Doc | +|---------|---------|------------| +| v0.39.0 | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. | +| v0.39.1 | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. | +| v0.39.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. | +| v0.39.3 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library, migrate `git-board` to library-backed surface. | + +--- + ## MVP v0.50.0 **Gate:** deploy for 5–10 teams, ~50 users, 100+ anonymous visitors diff --git a/packages/sdk-test-runner/css/main.css b/packages/sdk-test-runner/css/main.css new file mode 100644 index 0000000..6c59fe2 --- /dev/null +++ b/packages/sdk-test-runner/css/main.css @@ -0,0 +1,222 @@ +/* SDK Test Runner — Styles */ + +.sdkr-header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 4px; +} +.sdkr-header h1 { + font-size: 20px; + font-weight: 600; + margin: 0; + color: var(--text, #e0e0e0); +} +.sdkr-version { + font-size: 12px; + color: var(--text3, #888); + font-family: 'JetBrains Mono', monospace; +} +.sdkr-desc { + font-size: 13px; + color: var(--text2, #aaa); + margin: 0 0 16px 0; + line-height: 1.5; +} + +/* Fixtures panel */ +.sdkr-fixtures { + margin-bottom: 16px; + padding: 10px 14px; + border: 1px solid var(--border, #333); + border-radius: 6px; + background: var(--bg1, #1a1a1a); +} +.sdkr-fixture-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.sdkr-fixture-label { + font-size: 12px; + font-weight: 600; + color: var(--text2, #aaa); + text-transform: uppercase; + white-space: nowrap; +} +.sdkr-select { + padding: 4px 8px; + font-size: 13px; + background: var(--bg2, #222); + color: var(--text, #e0e0e0); + border: 1px solid var(--border, #444); + border-radius: 4px; + min-width: 130px; +} +.sdkr-input { + padding: 4px 8px; + font-size: 13px; + background: var(--bg2, #222); + color: var(--text, #e0e0e0); + border: 1px solid var(--border, #444); + border-radius: 4px; + flex: 1; + min-width: 200px; + max-width: 400px; + font-family: 'JetBrains Mono', monospace; +} +.sdkr-input::placeholder { + color: var(--text3, #666); + font-family: inherit; +} +.sdkr-fixture-status { + font-size: 12px; + color: var(--text3, #888); + font-family: 'JetBrains Mono', monospace; +} +.sdkr-fixture-note { + font-size: 11px; + color: var(--text3, #666); + margin-top: 6px; + line-height: 1.4; +} + +/* Controls */ +.sdkr-controls { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} +.sdkr-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.sdkr-filter-label { + font-size: 12px; + font-weight: 600; + color: var(--text2, #aaa); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.sdkr-check { + font-size: 13px; + color: var(--text, #e0e0e0); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 3px; +} +.sdkr-check input { cursor: pointer; } +.sdkr-btn-row { + display: flex; + gap: 8px; +} +.sdkr-btn { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border, #444); + background: var(--bg2, #2a2a2a); + color: var(--text, #e0e0e0); + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} +.sdkr-btn:hover { background: var(--bg3, #383838); } +.sdkr-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.sdkr-btn-primary { + background: var(--accent, #3b82f6); + border-color: var(--accent, #3b82f6); + color: #fff; +} +.sdkr-btn-primary:hover { filter: brightness(1.15); } +.sdkr-btn-danger { + background: var(--danger, #ef4444); + border-color: var(--danger, #ef4444); + color: #fff; +} + +/* Summary */ +.sdkr-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + padding: 10px 12px; + background: var(--bg2, #1e1e1e); + border-radius: 8px; + border: 1px solid var(--border, #333); +} +.sdkr-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; +} + +/* Table */ +.sdkr-table-wrap { + overflow-x: auto; + border: 1px solid var(--border, #333); + border-radius: 8px; +} +.sdkr-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.sdkr-table th { + text-align: left; + padding: 8px 10px; + background: var(--bg2, #1e1e1e); + color: var(--text2, #aaa); + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border, #333); + position: sticky; + top: 0; + z-index: 1; +} +.sdkr-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--border-subtle, #2a2a2a); + color: var(--text, #e0e0e0); + vertical-align: top; +} +.sdkr-table tbody tr:hover { + background: var(--bg-hover, rgba(255,255,255,0.03)); +} +.sdkr-test-name { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; +} +.sdkr-verdict { + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} +.sdkr-ms { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text3, #888); + text-align: right; +} +.sdkr-detail { + font-size: 11px; + color: var(--text3, #888); + max-width: 400px; + word-break: break-word; +} + +/* Row verdict highlights */ +.sdkr-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); } +.sdkr-row-icd_bug td { background: rgba(239, 68, 68, 0.06); } +.sdkr-row-shape_bug td { background: rgba(168, 85, 247, 0.06); } +.sdkr-row-error td { background: rgba(239, 68, 68, 0.08); } diff --git a/packages/sdk-test-runner/js/domains/admin.js b/packages/sdk-test-runner/js/domains/admin.js new file mode 100644 index 0000000..fc961cf --- /dev/null +++ b/packages/sdk-test-runner/js/domains/admin.js @@ -0,0 +1,153 @@ +/** + * SDK Test Runner — Domain: admin + * + * Tests admin-scoped endpoints. Requires admin role. + * KNOWN ISSUES: some admin list endpoints use non-standard envelope + * keys (§E in ICD drift audit). + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('admin', async function () { + + // Guard: skip if not admin + if (!window.sw || !sw.isAdmin) { + await T.test('admin', 'guard', 'skip — not admin', async function () { + T.assert(true, 'skipped (user is not admin)'); + }); + return; + } + + // ── Users ── + + await T.test('admin', 'users', 'sw.api.admin.users.list()', { + sdk: function () { return sw.api.admin.users.list(); }, + raw: { method: 'GET', path: '/admin/users' }, + validate: function (r) { + // KNOWN: backend returns {users: [...]} not {data: [...]} + var arr = r; + if (r && r.users) arr = r.users; + if (r && r.data) arr = r.data; + T.assert(Array.isArray(arr), 'expected array (maybe wrapped in non-standard key)'); + } + }); + + // ── Configs (provider configs) ── + + await T.test('admin', 'configs', 'sw.api.admin.configs.list()', { + sdk: function () { return sw.api.admin.configs.list(); }, + raw: { method: 'GET', path: '/admin/configs' }, + validate: function (r) { + // KNOWN: backend returns {configs: [...]} not {data: [...]} + var arr = r; + if (r && r.configs) arr = r.configs; + if (r && r.data) arr = r.data; + T.assert(Array.isArray(arr), 'expected array'); + } + }); + + // ── Models ── + + await T.test('admin', 'models', 'sw.api.admin.models.list()', { + sdk: function () { return sw.api.admin.models.list(); }, + raw: { method: 'GET', path: '/admin/models' }, + validate: function (r) { + // KNOWN: backend returns {models: [...]} not {data: [...]} + var arr = r; + if (r && r.models) arr = r.models; + if (r && r.data) arr = r.data; + T.assert(Array.isArray(arr), 'expected array'); + } + }); + + // ── Settings ── + + await T.test('admin', 'settings', 'sw.api.admin.settings.get()', { + sdk: function () { return sw.api.admin.settings.get(); }, + raw: { method: 'GET', path: '/admin/settings' }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + // ── Packages ── + + await T.test('admin', 'packages', 'sw.api.admin.packages.list()', { + sdk: function () { return sw.api.admin.packages.list(); }, + raw: { method: 'GET', path: '/admin/packages' }, + validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); } + }); + + // ── Surfaces ── + + await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', { + sdk: function () { return sw.api.admin.surfaces.list(); }, + raw: { method: 'GET', path: '/admin/surfaces' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Extensions ── + + await T.test('admin', 'extensions', 'sw.api.admin.extensions.list()', { + sdk: function () { return sw.api.admin.extensions.list(); }, + raw: { method: 'GET', path: '/admin/extensions' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Tasks ── + + await T.test('admin', 'tasks', 'sw.api.admin.tasks.list()', { + sdk: function () { return sw.api.admin.tasks.list(); }, + raw: { method: 'GET', path: '/admin/tasks' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Routing ── + + await T.test('admin', 'routing', 'sw.api.admin.routing.policies()', { + sdk: function () { return sw.api.admin.routing.policies(); }, + raw: { method: 'GET', path: '/admin/routing/policies' }, + validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); } + }); + + // ── Provider health ── + + await T.test('admin', 'providers', 'sw.api.admin.providers.health()', { + sdk: function () { return sw.api.admin.providers.health(); }, + raw: { method: 'GET', path: '/admin/providers/health' }, + validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); } + }); + + // ── Storage ── + + await T.test('admin', 'storage', 'sw.api.admin.storage.status()', { + sdk: function () { return sw.api.admin.storage.status(); }, + raw: { method: 'GET', path: '/admin/storage/status' }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + // ── Vault ── + + await T.test('admin', 'vault', 'sw.api.admin.vault.status()', { + sdk: function () { return sw.api.admin.vault.status(); }, + raw: { method: 'GET', path: '/admin/vault/status' }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + // ── Channels (archived) ── + + await T.test('admin', 'channels', 'sw.api.admin.channels.archived()', { + sdk: function () { return sw.api.admin.channels.archived(); }, + raw: { method: 'GET', path: '/admin/channels/archived' }, + validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); } + }); + + // ── Projects ── + + await T.test('admin', 'projects', 'sw.api.admin.projects.list()', { + sdk: function () { return sw.api.admin.projects.list(); }, + raw: { method: 'GET', path: '/admin/projects' }, + validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/channels.js b/packages/sdk-test-runner/js/domains/channels.js new file mode 100644 index 0000000..4341ec5 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/channels.js @@ -0,0 +1,158 @@ +/** + * SDK Test Runner — Domain: channels + * + * Tests: list, create, get, update, delete, messages, participants, + * models, kbs, files, markRead, generateTitle, path, siblings. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('channels', async function () { + var tag = 'sdkr-' + Date.now(); + var channelId = null; + + // ── List ── + + await T.test('channels', 'list', 'sw.api.channels.list() returns array', { + sdk: function () { return sw.api.channels.list(); }, + raw: { method: 'GET', path: '/channels' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Create ── + + await T.test('channels', 'crud', 'sw.api.channels.create() returns channel shape', { + sdk: function () { + return sw.api.channels.create({ title: tag + '-ch', type: 'direct' }); + }, + raw: { method: 'POST', path: '/channels', body: { title: tag + '-ch-raw', type: 'direct' } }, + validate: function (r) { + T.assertShape(r, T.S.channel, 'channel'); + channelId = r.id; + T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); }); + } + }); + + if (!channelId) return; + + // ── Get ── + + await T.test('channels', 'crud', 'sw.api.channels.get(id) returns full shape', { + sdk: function () { return sw.api.channels.get(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId }, + validate: function (r) { + T.assertShape(r, T.S.channelFull, 'channelFull'); + T.assert(r.id === channelId, 'id mismatch'); + } + }); + + // ── Update ── + + await T.test('channels', 'crud', 'sw.api.channels.update(id, data)', { + sdk: function () { return sw.api.channels.update(channelId, { title: tag + '-updated' }); }, + raw: { method: 'PUT', path: '/channels/' + channelId, body: { title: tag + '-updated' } }, + validate: function (r) { + T.assertShape(r, T.S.channelFull, 'channel'); + T.assert(r.title === tag + '-updated', 'title not updated'); + } + }); + + // ── Messages ── + + await T.test('channels', 'messages', 'sw.api.channels.messages(id) returns array', { + sdk: function () { return sw.api.channels.messages(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/messages' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Send message ── + + var msgId = null; + await T.test('channels', 'messages', 'sw.api.channels.send(id, data)', { + sdk: function () { + return sw.api.channels.send(channelId, { content: 'sdk-test-msg', role: 'user' }); + }, + raw: { method: 'POST', path: '/channels/' + channelId + '/messages', + body: { content: 'sdk-test-msg', role: 'user' } }, + validate: function (r) { + T.assertShape(r, T.S.message, 'message'); + msgId = r.id; + } + }); + + // ── Siblings ── + + if (msgId) { + await T.test('channels', 'messages', 'sw.api.channels.siblings(id, msgId)', { + sdk: function () { return sw.api.channels.siblings(channelId, msgId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/messages/' + msgId + '/siblings' }, + validate: function (r) { + // Siblings returns { siblings: [...], current_index, total } + var arr = r.siblings || T.unwrapList(r); + T.assert(Array.isArray(arr), 'expected siblings array'); + } + }); + } + + // ── Path ── + + await T.test('channels', 'tree', 'sw.api.channels.path(id)', { + sdk: function () { return sw.api.channels.path(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/path' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + // ── Mark Read ── + + await T.test('channels', 'read', 'sw.api.channels.markRead(id)', { + sdk: function () { return sw.api.channels.markRead(channelId); }, + raw: { method: 'POST', path: '/channels/' + channelId + '/mark-read', body: {} }, + validate: function () { /* 200 OK is sufficient */ } + }); + + // ── KBs ── + + await T.test('channels', 'kbs', 'sw.api.channels.kbs(id) returns array', { + sdk: function () { return sw.api.channels.kbs(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/knowledge-bases' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + // ── Files ── + + await T.test('channels', 'files', 'sw.api.channels.files(id)', { + sdk: function () { return sw.api.channels.files(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/files' }, + validate: function (r) { + // May be array or {files: [], count: N} + T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); + } + }); + + // ── Participants ── + + await T.test('channels', 'participants', 'sw.api.channels.participants(id)', { + sdk: function () { return sw.api.channels.participants(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/participants' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + // ── Models ── + + await T.test('channels', 'models', 'sw.api.channels.models(id)', { + sdk: function () { return sw.api.channels.models(channelId); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/models' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + // ── Delete ── + + await T.test('channels', 'crud', 'sw.api.channels.del(id)', { + sdk: function () { return sw.api.channels.del(channelId); }, + raw: { method: 'DELETE', path: '/channels/' + channelId }, + validate: function () { channelId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/knowledge.js b/packages/sdk-test-runner/js/domains/knowledge.js new file mode 100644 index 0000000..6f3ea09 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/knowledge.js @@ -0,0 +1,82 @@ +/** + * SDK Test Runner — Domain: knowledge + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('knowledge', async function () { + var tag = 'sdkr-' + Date.now(); + var kbId = null; + + await T.test('knowledge', 'list', 'sw.api.knowledge.list()', { + sdk: function () { return sw.api.knowledge.list(); }, + raw: { method: 'GET', path: '/knowledge-bases' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + await T.test('knowledge', 'crud', 'sw.api.knowledge.create()', { + sdk: function () { + return sw.api.knowledge.create({ name: tag + '-kb', scope: 'personal' }); + }, + raw: { method: 'POST', path: '/knowledge-bases', body: { name: tag + '-kb', scope: 'personal' } }, + validate: function (r) { + T.assertShape(r, T.S.kb, 'kb'); + kbId = r.id; + T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); }); + } + }); + + if (!kbId) return; + + await T.test('knowledge', 'crud', 'sw.api.knowledge.get(id)', { + sdk: function () { return sw.api.knowledge.get(kbId); }, + raw: { method: 'GET', path: '/knowledge-bases/' + kbId }, + validate: function (r) { + T.assertShape(r, T.S.kb, 'kb'); + T.assert(r.id === kbId, 'id mismatch'); + } + }); + + await T.test('knowledge', 'crud', 'sw.api.knowledge.update(id, data)', { + sdk: function () { return sw.api.knowledge.update(kbId, { name: tag + '-kb-upd' }); }, + raw: { method: 'PUT', path: '/knowledge-bases/' + kbId, body: { name: tag + '-kb-upd' } }, + validate: function (r) { T.assertShape(r, T.S.kb, 'kb'); } + }); + + // ── Discoverable ── + + await T.test('knowledge', 'discover', 'sw.api.knowledge.discoverable()', { + sdk: function () { return sw.api.knowledge.discoverable(); }, + raw: { method: 'GET', path: '/knowledge-bases-discoverable' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Documents ── + + await T.test('knowledge', 'docs', 'sw.api.knowledge.documents(id)', { + sdk: function () { return sw.api.knowledge.documents(kbId); }, + raw: { method: 'GET', path: '/knowledge-bases/' + kbId + '/documents' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Search ── + + // Search requires an embedding provider — only run if fixture provisioned one + var hasEmbedding = T.fixtures && T.fixtures.embeddingModel; + await T.test('knowledge', 'search', 'sw.api.knowledge.search(id, query)', { + sdk: function () { return sw.api.knowledge.search(kbId, 'test', 5); }, + raw: { method: 'POST', path: '/knowledge-bases/' + kbId + '/search', + body: { query: 'test', limit: 5 } }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }, + skip: hasEmbedding ? null : 'no embedding provider configured — set up a provider with embedding models' + }); + + await T.test('knowledge', 'crud', 'sw.api.knowledge.del(id)', { + sdk: function () { return sw.api.knowledge.del(kbId); }, + raw: { method: 'DELETE', path: '/knowledge-bases/' + kbId }, + validate: function () { kbId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/misc.js b/packages/sdk-test-runner/js/domains/misc.js new file mode 100644 index 0000000..5cc1955 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/misc.js @@ -0,0 +1,196 @@ +/** + * SDK Test Runner — Domain: misc + * + * Covers smaller domains: notifications, folders, profile, + * data portability, health, models, users, presence, usage, groups, + * tools, git. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('misc', async function () { + var tag = 'sdkr-' + Date.now(); + + // ═══════════════════════════════════════════════════════════ + // HEALTH + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'health', 'sw.api.health()', { + sdk: function () { return sw.api.health(); }, + raw: { method: 'GET', path: '/health' }, + validate: function (r) { T.assert(r.status === 'ok', 'status should be ok'); } + }); + + // ═══════════════════════════════════════════════════════════ + // PROFILE + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'profile', 'sw.api.profile.get()', { + sdk: function () { return sw.api.profile.get(); }, + raw: { method: 'GET', path: '/profile' }, + validate: function (r) { T.assertShape(r, T.S.profile, 'profile'); } + }); + + await T.test('misc', 'profile', 'sw.api.profile.permissions()', { + sdk: function () { + if (sw.api.profile.permissions) return sw.api.profile.permissions(); + throw new Error('sw.api.profile.permissions not defined'); + }, + raw: { method: 'GET', path: '/profile/permissions' }, + validate: function (r) { + T.assert(Array.isArray(r.permissions), 'permissions should be array'); + } + }); + + // ═══════════════════════════════════════════════════════════ + // MODELS + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'models', 'sw.api.models.enabled()', { + sdk: function () { + if (sw.api.models && sw.api.models.enabled) return sw.api.models.enabled(); + throw new Error('sw.api.models.enabled not defined'); + }, + raw: { method: 'GET', path: '/models/enabled' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ═══════════════════════════════════════════════════════════ + // FOLDERS + // ═══════════════════════════════════════════════════════════ + + var folderId = null; + + await T.test('misc', 'folders', 'sw.api.folders.list()', { + sdk: function () { return sw.api.folders.list(); }, + raw: { method: 'GET', path: '/folders' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + await T.test('misc', 'folders', 'sw.api.folders.create()', { + sdk: function () { + return sw.api.folders.create(tag + '-folder'); + }, + raw: { method: 'POST', path: '/folders', body: { name: tag + '-folder', parent_id: null, sort_order: 0 } }, + validate: function (r) { + // Backend returns { folder: {...} } — SDK passes envelope through + var obj = r.folder || r; + T.assertShape(obj, T.S.folder, 'folder'); + folderId = obj.id; + T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); }); + } + }); + + if (folderId) { + await T.test('misc', 'folders', 'sw.api.folders.update(id, data)', { + sdk: function () { return sw.api.folders.update(folderId, { name: tag + '-fold-upd' }); }, + raw: { method: 'PUT', path: '/folders/' + folderId, body: { name: tag + '-fold-upd' } }, + validate: function (r) { T.assert(r && (r.ok || r.id || r.folder), 'expected ok or folder'); } + }); + + await T.test('misc', 'folders', 'sw.api.folders.del(id)', { + sdk: function () { return sw.api.folders.del(folderId); }, + raw: { method: 'DELETE', path: '/folders/' + folderId }, + validate: function () { folderId = null; } + }); + } + + // ═══════════════════════════════════════════════════════════ + // NOTIFICATIONS (duplicate key bug fixed — prefs/setPref/delPref restored) + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'notifications', 'sw.api.notifications.list()', { + sdk: function () { return sw.api.notifications.list(); }, + raw: { method: 'GET', path: '/notifications' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + await T.test('misc', 'notifications', 'sw.api.notifications.unreadCount()', { + sdk: function () { return sw.api.notifications.unreadCount(); }, + raw: { method: 'GET', path: '/notifications/unread-count' }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + await T.test('misc', 'notifications', 'sw.api.notifications.prefs()', { + sdk: function () { + if (typeof sw.api.notifications.prefs === 'function') return sw.api.notifications.prefs(); + throw new Error('sw.api.notifications.prefs is ' + typeof sw.api.notifications.prefs); + }, + raw: { method: 'GET', path: '/notifications/preferences' }, + validate: function () { /* existence is sufficient */ } + }); + + // ═══════════════════════════════════════════════════════════ + // USERS + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'users', 'sw.api.users.search(q)', { + sdk: function () { return sw.api.users.search('admin'); }, + raw: { method: 'GET', path: '/users/search?q=admin' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ═══════════════════════════════════════════════════════════ + // PRESENCE + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'presence', 'sw.api.presence.heartbeat()', { + sdk: function () { return sw.api.presence.heartbeat(); }, + raw: { method: 'POST', path: '/presence/heartbeat', body: {} }, + validate: function () { /* 200 OK is sufficient */ } + }); + + // ═══════════════════════════════════════════════════════════ + // USAGE + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'usage', 'sw.api.usage.mine()', { + sdk: function () { return sw.api.usage.mine(); }, + raw: { method: 'GET', path: '/usage' }, + validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); } + }); + + // ═══════════════════════════════════════════════════════════ + // GROUPS + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'groups', 'sw.api.groups.mine()', { + sdk: function () { return sw.api.groups.mine(); }, + raw: { method: 'GET', path: '/groups/mine' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ═══════════════════════════════════════════════════════════ + // TOOLS + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'tools', 'sw.api.tools.list()', { + sdk: function () { return sw.api.tools.list(); }, + raw: { method: 'GET', path: '/tools' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ═══════════════════════════════════════════════════════════ + // DATA PORTABILITY (KNOWN BUG: deleteAccount wrong method+path) + // ═══════════════════════════════════════════════════════════ + + await T.test('misc', 'export', 'sw.api.dataPortability.exportMe()', { + sdk: function () { return sw.api.dataPortability.exportMe(); }, + raw: { method: 'GET', path: '/export/me' }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }, + skip: 'empty DB export may 500 — expected in dev' + }); + + // deleteAccount: SDK uses POST /profile/delete, backend is DELETE /me + // We don't actually call delete — just verify the method exists + await T.test('misc', 'export', 'sw.api.dataPortability.deleteAccount exists', async function () { + T.assert(typeof sw.api.dataPortability.deleteAccount === 'function', + 'deleteAccount should be a function'); + // NOTE: SDK calls POST /profile/delete — backend is DELETE /me. + // This is a known SDK_BUG from the ICD drift audit (§A). + // We don't actually invoke it because it would delete the account. + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/notes.js b/packages/sdk-test-runner/js/domains/notes.js new file mode 100644 index 0000000..7326176 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/notes.js @@ -0,0 +1,106 @@ +/** + * SDK Test Runner — Domain: notes + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('notes', async function () { + var tag = 'sdkr-' + Date.now(); + var noteId = null; + + // ── List ── + + await T.test('notes', 'list', 'sw.api.notes.list()', { + sdk: function () { return sw.api.notes.list(); }, + raw: { method: 'GET', path: '/notes' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Create ── + + await T.test('notes', 'crud', 'sw.api.notes.create()', { + sdk: function () { + return sw.api.notes.create({ title: tag + '-note', content: '# Test\nBody text' }); + }, + raw: { method: 'POST', path: '/notes', body: { title: tag + '-note', content: '# Test\nBody text' } }, + validate: function (r) { + T.assertShape(r, T.S.note, 'note'); + noteId = r.id; + T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); }); + } + }); + + if (!noteId) return; + + // ── Get ── + + await T.test('notes', 'crud', 'sw.api.notes.get(id)', { + sdk: function () { return sw.api.notes.get(noteId); }, + raw: { method: 'GET', path: '/notes/' + noteId }, + validate: function (r) { + T.assertShape(r, T.S.note, 'note'); + T.assert(r.id === noteId, 'id mismatch'); + } + }); + + // ── Update ── + + await T.test('notes', 'crud', 'sw.api.notes.update(id, data)', { + sdk: function () { return sw.api.notes.update(noteId, { title: tag + '-note-upd', content: 'updated' }); }, + raw: { method: 'PUT', path: '/notes/' + noteId, body: { title: tag + '-note-upd', content: 'updated' } }, + validate: function (r) { + T.assertShape(r, T.S.note, 'note'); + } + }); + + // ── Search ── + + await T.test('notes', 'search', 'sw.api.notes.search(query)', { + sdk: function () { return sw.api.notes.search(tag, 5); }, + raw: { method: 'GET', path: '/notes/search?q=' + encodeURIComponent(tag) + '&limit=5' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Search Titles ── + + await T.test('notes', 'search', 'sw.api.notes.searchTitles(query)', { + sdk: function () { return sw.api.notes.searchTitles(tag, 5); }, + raw: { method: 'GET', path: '/notes/search-titles?q=' + encodeURIComponent(tag) + '&limit=5' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Folders ── + + await T.test('notes', 'folders', 'sw.api.notes.folders()', { + sdk: function () { return sw.api.notes.folders(); }, + raw: { method: 'GET', path: '/notes/folders' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Backlinks ── + + await T.test('notes', 'links', 'sw.api.notes.backlinks(id)', { + sdk: function () { return sw.api.notes.backlinks(noteId); }, + raw: { method: 'GET', path: '/notes/' + noteId + '/backlinks' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Graph ── + + await T.test('notes', 'graph', 'sw.api.notes.graph()', { + sdk: function () { return sw.api.notes.graph(); }, + raw: { method: 'GET', path: '/notes/graph' }, + validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); } + }); + + // ── Delete ── + + await T.test('notes', 'crud', 'sw.api.notes.del(id)', { + sdk: function () { return sw.api.notes.del(noteId); }, + raw: { method: 'DELETE', path: '/notes/' + noteId }, + validate: function () { noteId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/personas.js b/packages/sdk-test-runner/js/domains/personas.js new file mode 100644 index 0000000..492c682 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/personas.js @@ -0,0 +1,66 @@ +/** + * SDK Test Runner — Domain: personas + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('personas', async function () { + var tag = 'sdkr-' + Date.now(); + var personaId = null; + + // Policy: allow_user_personas is toggled by fixtures.js. + // If fixtures didn't run (or user isn't admin), create will 403. + + await T.test('personas', 'list', 'sw.api.personas.list()', { + sdk: function () { return sw.api.personas.list(); }, + raw: { method: 'GET', path: '/personas' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + await T.test('personas', 'crud', 'sw.api.personas.create()', { + sdk: function () { + return sw.api.personas.create({ + name: tag + '-persona', system_prompt: 'You are a test bot.', + scope: 'personal' + }); + }, + raw: { method: 'POST', path: '/personas', body: { + name: tag + '-persona', system_prompt: 'You are a test bot.', + scope: 'personal' + }}, + validate: function (r) { + T.assertShape(r, T.S.persona, 'persona'); + personaId = r.id; + T.registerCleanup(function () { if (personaId) return T.safeDelete('/personas/' + personaId); }); + } + }); + + if (!personaId) return; + + // NOTE: User-scoped personas only have LIST + CREATE routes. + // GET/PUT/DELETE /personas/:id are admin-only (ICD gap). + // Use admin endpoints for full CRUD testing. + + await T.test('personas', 'crud', 'sw.api.admin.personas.update(id, data)', { + sdk: function () { return sw.api.admin.personas.update(personaId, { name: tag + '-pers-upd' }); }, + raw: { method: 'PUT', path: '/admin/personas/' + personaId, body: { name: tag + '-pers-upd' } }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + // ── KBs ── + + await T.test('personas', 'kbs', 'sw.api.admin.personas.kbs(id)', { + sdk: function () { return sw.api.admin.personas.kbs(personaId); }, + raw: { method: 'GET', path: '/admin/personas/' + personaId + '/knowledge-bases' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + await T.test('personas', 'crud', 'sw.api.admin.personas.del(id)', { + sdk: function () { return sw.api.admin.personas.del(personaId); }, + raw: { method: 'DELETE', path: '/admin/personas/' + personaId }, + validate: function () { personaId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/projects.js b/packages/sdk-test-runner/js/domains/projects.js new file mode 100644 index 0000000..c897f18 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/projects.js @@ -0,0 +1,134 @@ +/** + * SDK Test Runner — Domain: projects + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('projects', async function () { + var tag = 'sdkr-' + Date.now(); + var projectId = null; + var channelId = null; + var noteId = null; + + // ── List ── + + await T.test('projects', 'list', 'sw.api.projects.list()', { + sdk: function () { return sw.api.projects.list(); }, + raw: { method: 'GET', path: '/projects' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + // ── Create ── + + await T.test('projects', 'crud', 'sw.api.projects.create()', { + sdk: function () { + return sw.api.projects.create({ name: tag + '-proj', description: 'sdk test' }); + }, + raw: { method: 'POST', path: '/projects', body: { name: tag + '-proj', description: 'sdk test' } }, + validate: function (r) { + T.assertShape(r, T.S.project, 'project'); + projectId = r.id; + T.registerCleanup(function () { if (projectId) return T.safeDelete('/projects/' + projectId); }); + } + }); + + if (!projectId) return; + + // ── Get ── + + await T.test('projects', 'crud', 'sw.api.projects.get(id)', { + sdk: function () { return sw.api.projects.get(projectId); }, + raw: { method: 'GET', path: '/projects/' + projectId }, + validate: function (r) { + T.assertShape(r, T.S.project, 'project'); + T.assert(r.id === projectId, 'id mismatch'); + } + }); + + // ── Update ── + + await T.test('projects', 'crud', 'sw.api.projects.update(id, data)', { + sdk: function () { return sw.api.projects.update(projectId, { name: tag + '-proj-upd' }); }, + raw: { method: 'PUT', path: '/projects/' + projectId, body: { name: tag + '-proj-upd' } }, + validate: function (r) { + T.assertShape(r, T.S.project, 'project'); + } + }); + + // ── Channel association ── + + await T.test('projects', 'assoc', 'sw.api.projects.addChannel()', async function () { + var ch = await T.raw.post('/channels', { title: tag + '-proj-ch', type: 'direct' }); + channelId = ch.id; + T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); }); + }); + + if (channelId) { + await T.test('projects', 'assoc', 'sw.api.projects.addChannel(id, chId)', { + sdk: function () { return sw.api.projects.addChannel(projectId, channelId, 0); }, + raw: { method: 'POST', path: '/projects/' + projectId + '/channels', + body: { channel_id: channelId, position: 0 } }, + validate: function () { /* ok */ } + }); + + await T.test('projects', 'assoc', 'sw.api.projects.channels(id)', { + sdk: function () { return sw.api.projects.channels(projectId); }, + raw: { method: 'GET', path: '/projects/' + projectId + '/channels' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + await T.test('projects', 'assoc', 'sw.api.projects.removeChannel()', { + sdk: function () { return sw.api.projects.removeChannel(projectId, channelId); }, + raw: { method: 'DELETE', path: '/projects/' + projectId + '/channels/' + channelId }, + validate: function () { /* ok */ } + }); + } + + // ── Note association ── + + await T.test('projects', 'assoc', 'create scratch note for project', async function () { + var n = await T.raw.post('/notes', { title: tag + '-proj-note', content: 'test' }); + noteId = n.id; + T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); }); + }); + + if (noteId) { + await T.test('projects', 'assoc', 'sw.api.projects.addNote()', { + sdk: function () { return sw.api.projects.addNote(projectId, noteId); }, + raw: { method: 'POST', path: '/projects/' + projectId + '/notes', + body: { note_id: noteId } }, + validate: function () { /* ok */ } + }); + + await T.test('projects', 'assoc', 'sw.api.projects.notes(id)', { + sdk: function () { return sw.api.projects.notes(projectId); }, + raw: { method: 'GET', path: '/projects/' + projectId + '/notes' }, + validate: function (r) { T.assert(Array.isArray(r), 'expected array'); } + }); + + await T.test('projects', 'assoc', 'sw.api.projects.removeNote()', { + sdk: function () { return sw.api.projects.removeNote(projectId, noteId); }, + raw: { method: 'DELETE', path: '/projects/' + projectId + '/notes/' + noteId }, + validate: function () { /* ok */ } + }); + } + + // ── Files ── + + await T.test('projects', 'files', 'sw.api.projects.files(id)', { + sdk: function () { return sw.api.projects.files(projectId); }, + raw: { method: 'GET', path: '/projects/' + projectId + '/files' }, + validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected obj/arr'); } + }); + + // ── Delete ── + + await T.test('projects', 'crud', 'sw.api.projects.del(id)', { + sdk: function () { return sw.api.projects.del(projectId); }, + raw: { method: 'DELETE', path: '/projects/' + projectId }, + validate: function () { projectId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/tasks.js b/packages/sdk-test-runner/js/domains/tasks.js new file mode 100644 index 0000000..e8b54d4 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/tasks.js @@ -0,0 +1,107 @@ +/** + * SDK Test Runner — Domain: tasks + * + * KNOWN BUGS: + * sw.api.tasks.start(id) → POST /tasks/:id/start (backend: /tasks/:id/run) + * sw.api.tasks.stop(id) → POST /tasks/:id/stop (backend: /tasks/:id/kill) + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('tasks', async function () { + var tag = 'sdkr-' + Date.now(); + var taskId = null; + + // ── List ── + + await T.test('tasks', 'list', 'sw.api.tasks.list()', { + sdk: function () { return sw.api.tasks.list(); }, + raw: { method: 'GET', path: '/tasks' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Create ── + + await T.test('tasks', 'crud', 'sw.api.tasks.create()', { + sdk: function () { + return sw.api.tasks.create({ + name: tag + '-task', task_type: 'prompt', + schedule: '@daily', user_prompt: 'SDK test prompt', is_active: false + }); + }, + raw: { method: 'POST', path: '/tasks', body: { + name: tag + '-task', task_type: 'prompt', + schedule: '@daily', user_prompt: 'SDK test prompt', is_active: false + }}, + validate: function (r) { + T.assertShape(r, T.S.task, 'task'); + taskId = r.id; + T.registerCleanup(function () { if (taskId) return T.safeDelete('/tasks/' + taskId); }); + } + }); + + if (!taskId) return; + + // ── Get ── + + await T.test('tasks', 'crud', 'sw.api.tasks.get(id)', { + sdk: function () { return sw.api.tasks.get(taskId); }, + raw: { method: 'GET', path: '/tasks/' + taskId }, + validate: function (r) { + T.assert(r.id === taskId, 'id mismatch'); + } + }); + + // ── Update ── + + await T.test('tasks', 'crud', 'sw.api.tasks.update(id, data)', { + sdk: function () { return sw.api.tasks.update(taskId, { name: tag + '-task-upd' }); }, + raw: { method: 'PUT', path: '/tasks/' + taskId, body: { name: tag + '-task-upd' } }, + validate: function (r) { T.assert(typeof r === 'object', 'expected object'); } + }); + + // ── Start ── + + await T.test('tasks', 'lifecycle', 'sw.api.tasks.start(id)', { + sdk: function () { + if (sw.api.tasks.start) return sw.api.tasks.start(taskId); + throw new Error('sw.api.tasks.start not defined'); + }, + raw: { method: 'POST', path: '/tasks/' + taskId + '/run', body: {} }, + validate: function () { /* 200/202 is sufficient */ } + }); + + // ── Stop ── (404 expected when no active run) + + await T.test('tasks', 'lifecycle', 'sw.api.tasks.stop(id)', { + sdk: function () { + if (sw.api.tasks.stop) return sw.api.tasks.stop(taskId); + throw new Error('sw.api.tasks.stop not defined'); + }, + raw: { method: 'POST', path: '/tasks/' + taskId + '/kill', body: {} }, + validate: function () { /* 200/202 is sufficient */ }, + skip: 'no active run to cancel — 404 expected without task execution' + }); + + // ── Runs ── + + await T.test('tasks', 'runs', 'sw.api.tasks.runs(id)', { + sdk: function () { + if (sw.api.tasks.runs) return sw.api.tasks.runs(taskId); + throw new Error('sw.api.tasks.runs not defined'); + }, + raw: { method: 'GET', path: '/tasks/' + taskId + '/runs' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Delete ── + + await T.test('tasks', 'crud', 'sw.api.tasks.del(id)', { + sdk: function () { return sw.api.tasks.del(taskId); }, + raw: { method: 'DELETE', path: '/tasks/' + taskId }, + validate: function () { taskId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/workflows.js b/packages/sdk-test-runner/js/domains/workflows.js new file mode 100644 index 0000000..bddbea2 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/workflows.js @@ -0,0 +1,93 @@ +/** + * SDK Test Runner — Domain: workflows + * + * Tests: list, create, get, update, delete, stages. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('workflows', async function () { + var tag = 'sdkr-' + Date.now(); + var wfId = null; + + // ── List ── + + await T.test('workflows', 'list', 'sw.api.workflows.list() returns array', { + sdk: function () { return sw.api.workflows.list(); }, + raw: { method: 'GET', path: '/workflows' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Create ── + + await T.test('workflows', 'crud', 'sw.api.workflows.create()', { + sdk: function () { + return sw.api.workflows.create({ + name: tag + '-wf', slug: tag + '-wf', + entry_mode: 'team_only', scope: 'personal' + }); + }, + raw: { method: 'POST', path: '/workflows', body: { + name: tag + '-wf', slug: tag + '-wf', + entry_mode: 'team_only', scope: 'personal' + }}, + validate: function (r) { + T.assertShape(r, T.S.workflow, 'workflow'); + wfId = r.id; + T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); }); + } + }); + + if (!wfId) return; + + // ── Get ── + + await T.test('workflows', 'crud', 'sw.api.workflows.get(id)', { + sdk: function () { return sw.api.workflows.get(wfId); }, + raw: { method: 'GET', path: '/workflows/' + wfId }, + validate: function (r) { + T.assertShape(r, T.S.workflow, 'workflow'); + T.assert(r.id === wfId, 'id mismatch'); + } + }); + + // ── Update (KNOWN BUG: SDK uses PUT, backend expects PATCH) ── + + await T.test('workflows', 'crud', 'sw.api.workflows.update(id, data)', { + sdk: function () { + return sw.api.workflows.update(wfId, { name: tag + '-wf-updated' }); + }, + raw: { method: 'PATCH', path: '/workflows/' + wfId, + body: { name: tag + '-wf-updated' } }, + validate: function (r) { + T.assertShape(r, T.S.workflow, 'workflow'); + T.assert(r.name === tag + '-wf-updated', 'name not updated'); + } + }); + + // ── Stages ── + + var stageId = null; + + // stages.list via SDK (if wired) vs raw + await T.test('workflows', 'stages', 'GET /workflows/:id/stages (raw — SDK may not have this)', { + sdk: function () { + // SDK may or may not expose stages — test raw path + if (sw.api.workflows.stages) return sw.api.workflows.stages(wfId); + throw new Error('sw.api.workflows.stages not defined'); + }, + raw: { method: 'GET', path: '/workflows/' + wfId + '/stages' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Delete ── + + await T.test('workflows', 'crud', 'sw.api.workflows.del(id)', { + sdk: function () { return sw.api.workflows.del(wfId); }, + raw: { method: 'DELETE', path: '/workflows/' + wfId }, + validate: function () { wfId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/workspaces.js b/packages/sdk-test-runner/js/domains/workspaces.js new file mode 100644 index 0000000..6f2afe2 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/workspaces.js @@ -0,0 +1,83 @@ +/** + * SDK Test Runner — Domain: workspaces + * + * Tests: list, create, get, update, delete, files. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('workspaces', async function () { + var tag = 'sdkr-' + Date.now(); + var wsId = null; + + // Get current user for owner_type/owner_id + var me = await sw.api.profile.get(); + var userId = me.id; + + // ── List ── + + await T.test('workspaces', 'list', 'sw.api.workspaces.list()', { + sdk: function () { return sw.api.workspaces.list(); }, + raw: { method: 'GET', path: '/workspaces' }, + validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } + }); + + // ── Create ── + + await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', { + sdk: function () { + return sw.api.workspaces.create({ name: tag + '-ws', owner_type: 'user', owner_id: userId }); + }, + raw: { method: 'POST', path: '/workspaces', body: { name: tag + '-ws', owner_type: 'user', owner_id: userId } }, + validate: function (r) { + T.assertShape(r, T.S.workspace, 'workspace'); + wsId = r.id; + T.registerCleanup(function () { if (wsId) return T.safeDelete('/workspaces/' + wsId); }); + } + }); + + if (!wsId) return; + + // ── Get ── + + await T.test('workspaces', 'crud', 'sw.api.workspaces.get(id)', { + sdk: function () { return sw.api.workspaces.get(wsId); }, + raw: { method: 'GET', path: '/workspaces/' + wsId }, + validate: function (r) { + T.assertShape(r, T.S.workspace, 'workspace'); + T.assert(r.id === wsId, 'id mismatch'); + } + }); + + // ── Update (KNOWN BUG: SDK uses PUT, backend expects PATCH) ── + + await T.test('workspaces', 'crud', 'sw.api.workspaces.update(id, data)', { + sdk: function () { + return sw.api.workspaces.update(wsId, { name: tag + '-ws-updated' }); + }, + raw: { method: 'PATCH', path: '/workspaces/' + wsId, + body: { name: tag + '-ws-updated' } }, + validate: function (r) { + T.assertShape(r, T.S.workspace, 'workspace'); + } + }); + + // ── Files list ── + + await T.test('workspaces', 'files', 'sw.api.workspaces.files(id)', { + sdk: function () { return sw.api.workspaces.files(wsId); }, + raw: { method: 'GET', path: '/workspaces/' + wsId + '/files' }, + validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); } + }); + + // ── Delete ── + + await T.test('workspaces', 'crud', 'sw.api.workspaces.del(id)', { + sdk: function () { return sw.api.workspaces.del(wsId); }, + raw: { method: 'DELETE', path: '/workspaces/' + wsId }, + validate: function () { wsId = null; } + }); + }); +})(); diff --git a/packages/sdk-test-runner/js/fixtures.js b/packages/sdk-test-runner/js/fixtures.js new file mode 100644 index 0000000..adba0e6 --- /dev/null +++ b/packages/sdk-test-runner/js/fixtures.js @@ -0,0 +1,239 @@ +/** + * SDK Test Runner — Fixtures + * + * Provisions reusable test resources before domains run and tears + * them down afterward. Modeled on the ICD test runner's fixture + * pattern. + * + * Fixture resources (all optional — domains degrade gracefully): + * + * providerConfig Global API config created from the first admin + * config that has at least one embedding model. + * Enables: knowledge.search, model preference tests. + * + * embeddingModel {model_id, provider_config_id} picked from the + * enabled models list after the config is created. + * + * policies Policy toggles applied at setup, reverted at + * teardown (e.g. allow_user_personas). + * + * team Test team (with current user as admin). + * + * Access: window.SDKR.fixtures.* + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.fixtures = { + ready: false, + providerConfig: null, // { id, name, provider } + embeddingModel: null, // { model_id, provider_config_id } + team: null, // { id, name } + policies: {}, // key → original value (for revert) + errors: [] + }; + + // ─── Policy helpers ────────────────────────────────────── + + async function setPolicy(key, value) { + var fix = T.fixtures; + try { + // Save original for revert + if (!(key in fix.policies)) { + try { + var settings = await T.raw.get('/admin/settings'); + fix.policies[key] = settings.policies?.[key] ?? null; + } catch (_) { + fix.policies[key] = null; + } + } + await T.raw.put('/admin/settings/' + key, { value: String(value) }); + } catch (e) { + fix.errors.push('policy ' + key + ': ' + e.message); + } + } + + async function revertPolicies() { + var fix = T.fixtures; + var keys = Object.keys(fix.policies); + for (var i = 0; i < keys.length; i++) { + var val = fix.policies[keys[i]]; + try { + await T.raw.put('/admin/settings/' + keys[i], { + value: val === null ? 'false' : String(val) + }); + } catch (_) { /* best effort */ } + } + } + + // ─── Provider / model discovery ────────────────────────── + + async function provisionProvider() { + var fix = T.fixtures; + + // ── Read UI inputs (provider + API key) ── + var ui = T._fixtureInput || {}; + var selectedProvider = ui.provSelect ? ui.provSelect.value : ''; + var apiKey = ui.keyInput ? ui.keyInput.value.trim() : ''; + var statusEl = ui.statusEl; + + function setStatus(msg) { + if (statusEl) statusEl.textContent = msg; + console.log('[SDKR:fixtures] ' + msg); + } + + // Check if any configs already exist + try { + var configs = await T.raw.get('/admin/configs'); + var list = configs.data || configs.configs || []; + if (list.length > 0) { + fix.providerConfig = { id: list[0].id, name: list[0].name, provider: list[0].provider }; + setStatus('Using existing config: ' + list[0].name); + } + } catch (e) { + fix.errors.push('config list: ' + e.message); + return; + } + + // If no config exists and user provided a provider + key, create one + if (!fix.providerConfig && selectedProvider && apiKey) { + try { + var configData = { + name: 'sdkr-fixture-' + selectedProvider, + provider: selectedProvider, + api_key: apiKey, + scope: 'global' + }; + setStatus('Creating ' + selectedProvider + ' config...'); + var created = await T.raw.post('/admin/configs', configData); + if (created && created.id) { + fix.providerConfig = { id: created.id, name: configData.name, provider: selectedProvider }; + fix._createdConfig = true; // mark for cleanup + setStatus('Config created: ' + configData.name); + T.registerCleanup(function () { + if (fix._createdConfig && fix.providerConfig) { + return T.safeDelete('/admin/configs/' + fix.providerConfig.id); + } + }); + } + } catch (e) { + setStatus('Config create failed: ' + e.message); + fix.errors.push('config create: ' + e.message); + } + } else if (!fix.providerConfig && selectedProvider && !apiKey) { + // Provider selected but no key — try creating without key (some providers + // use server-side keys configured via env vars) + try { + var configData2 = { + name: 'sdkr-fixture-' + selectedProvider, + provider: selectedProvider, + scope: 'global' + }; + var created2 = await T.raw.post('/admin/configs', configData2); + if (created2 && created2.id) { + fix.providerConfig = { id: created2.id, name: configData2.name, provider: selectedProvider }; + fix._createdConfig = true; + setStatus('Config created (server key): ' + configData2.name); + T.registerCleanup(function () { + if (fix._createdConfig && fix.providerConfig) { + return T.safeDelete('/admin/configs/' + fix.providerConfig.id); + } + }); + } + } catch (_) { + setStatus('No config — skipping model tests'); + } + } else if (!fix.providerConfig) { + setStatus('No provider selected — model tests will be skipped'); + } + + // Fetch models if we have a config + if (fix.providerConfig) { + try { + await T.raw.post('/admin/models/fetch', {}); + // Wait briefly for async model fetch + await new Promise(function (r) { setTimeout(r, 1500); }); + } catch (_) { /* fetch may fail if no key */ } + + // Look for an embedding model + try { + var models = await T.raw.get('/models/enabled'); + var list2 = models.data || []; + for (var m = 0; m < list2.length; m++) { + var caps = list2[m].capabilities || {}; + if (caps.embedding || list2[m].model_type === 'embedding') { + fix.embeddingModel = { + model_id: list2[m].model_id, + provider_config_id: list2[m].provider_config_id + }; + console.log('[SDKR:fixtures] Embedding model: ' + list2[m].model_id); + break; + } + } + } catch (_) { /* no models available */ } + } + } + + // ─── Team ──────────────────────────────────────────────── + + async function provisionTeam() { + var fix = T.fixtures; + try { + var team = await T.raw.post('/admin/teams', { + name: 'sdkr-fixture-' + Date.now(), + description: 'SDK test runner fixture team' + }); + if (team && team.id) { + fix.team = { id: team.id, name: team.name }; + T.registerCleanup(function () { + if (fix.team) return T.safeDelete('/admin/teams/' + fix.team.id); + }); + } + } catch (e) { + fix.errors.push('team: ' + e.message); + } + } + + // ─── Provision / Teardown ──────────────────────────────── + + T.provisionFixtures = async function () { + var fix = T.fixtures; + if (fix.ready) return; + + console.log('[SDKR:fixtures] Provisioning...'); + + // Policies + await setPolicy('allow_user_personas', 'true'); + + // Provider + models + await provisionProvider(); + + // Team + await provisionTeam(); + + fix.ready = true; + + if (fix.errors.length > 0) { + console.warn('[SDKR:fixtures] ' + fix.errors.length + ' errors:', fix.errors); + } + console.log('[SDKR:fixtures] Ready —', + 'config:', fix.providerConfig ? fix.providerConfig.name : 'none', + '| embedding:', fix.embeddingModel ? fix.embeddingModel.model_id : 'none', + '| team:', fix.team ? fix.team.name : 'none' + ); + }; + + T.teardownFixtures = async function () { + await revertPolicies(); + // Cleanup functions registered via T.registerCleanup handle + // the config + team deletion. + T.fixtures = { + ready: false, providerConfig: null, embeddingModel: null, + team: null, policies: {}, errors: [] + }; + console.log('[SDKR:fixtures] Torn down'); + }; + +})(); diff --git a/packages/sdk-test-runner/js/framework.js b/packages/sdk-test-runner/js/framework.js new file mode 100644 index 0000000..0d9bce4 --- /dev/null +++ b/packages/sdk-test-runner/js/framework.js @@ -0,0 +1,357 @@ +/** + * SDK Test Runner — Framework + * + * Dual-path validation: every test runs through the SDK first. If it + * fails, a raw fetch against the ICD-specified endpoint isolates the + * fault: + * + * SDK pass → PASS (both layers good) + * SDK fail + ICD pass → SDK_BUG (wrong method/path/unwrap) + * SDK fail + ICD fail → ICD_BUG (backend broken) + * Validate fail → SHAPE_BUG (response doesn't match ICD contract) + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + // ─── Results ─────────────────────────────────────────────── + + T.results = []; + T.cleanup = []; + T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 }; + T.running = false; + T.aborted = false; + + // ─── Assertion Library ──────────────────────────────────── + + function assertType(val, type, path) { + var actual = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val; + if (type === 'array') { if (!Array.isArray(val)) return path + ': expected array, got ' + actual; } + else if (type === 'uuid') { if (typeof val !== 'string' || val.length < 8) return path + ': expected uuid'; } + else if (type === 'string?') { if (val !== null && val !== undefined && typeof val !== 'string') return path + ': expected string|null, got ' + actual; } + else if (type === 'number?') { if (val !== null && val !== undefined && typeof val !== 'number') return path + ': expected number|null, got ' + actual; } + else if (type === 'bool') { if (typeof val !== 'boolean') return path + ': expected bool, got ' + actual; } + else if (type === 'object') { if (typeof val !== 'object' || val === null || Array.isArray(val)) return path + ': expected object, got ' + actual; } + else if (type === 'object?') { if (val !== null && val !== undefined && (typeof val !== 'object' || Array.isArray(val))) return path + ': expected object|null, got ' + actual; } + else if (type === 'any') { /* always passes */ } + else { if (typeof val !== type) return path + ': expected ' + type + ', got ' + actual; } + return null; + } + + function validateShape(obj, schema, prefix) { + var errors = []; + prefix = prefix || '$'; + if (typeof obj !== 'object' || obj === null) { + return [prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj)]; + } + Object.keys(schema).forEach(function (key) { + var spec = schema[key]; + var val = obj[key]; + var path = prefix + '.' + key; + if (typeof spec === 'string') { + if (spec.charAt(spec.length - 1) === '?') { + if (val === undefined || val === null) return; + spec = spec.slice(0, -1); + } else if (val === undefined) { + errors.push(path + ': missing required field'); + return; + } + var e = assertType(val, spec, path); + if (e) errors.push(e); + } + }); + return errors; + } + + T.assert = function (condition, msg) { + if (!condition) throw new Error(msg || 'assertion failed'); + }; + + T.assertShape = function (obj, schema, label) { + var errs = validateShape(obj, schema, label || '$'); + if (errs.length) throw new Error(errs.join('; ')); + }; + + T.assertArrayOf = function (arr, schema, label) { + T.assert(Array.isArray(arr), (label || '$') + ': expected array, got ' + (typeof arr)); + if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]'); + }; + + /** + * Unwrap SDK list response — handles both bare array and paginated envelope. + * SDK _unwrap returns { data: [...], total, ... } when pagination metadata + * exists, or a bare array when the envelope has only { data: [...] }. + */ + T.unwrapList = function (r) { + if (Array.isArray(r)) return r; + if (r && Array.isArray(r.data)) return r.data; + throw new Error('expected array or { data: [...] } envelope, got ' + (typeof r)); + }; + + T.assertEnum = function (val, allowed, path) { + if (allowed.indexOf(val) === -1) + throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']'); + }; + + // ─── Raw Fetch (ICD path) ───────────────────────────────── + + async function _fetchJSON(method, path, body) { + var token = await T.getAuthToken(); + var opts = { + method: method, + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }; + if (body !== undefined && method !== 'GET' && method !== 'DELETE') + opts.body = JSON.stringify(body); + + var resp = await fetch(T.base + '/api/v1' + path, opts); + if (resp.status === 204) return { _status: 204 }; + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + if (!resp.ok) { + var err = new Error(method + ' ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : '')); + err.status = resp.status; + err.data = data; + throw err; + } + return data; + } + + T.raw = { + get: function (path) { return _fetchJSON('GET', path); }, + post: function (path, body) { return _fetchJSON('POST', path, body); }, + put: function (path, body) { return _fetchJSON('PUT', path, body); }, + patch: function (path, body) { return _fetchJSON('PATCH', path, body); }, + del: function (path) { return _fetchJSON('DELETE', path); }, + }; + + T.safeDelete = async function (path) { + try { await T.raw.del(path); } catch (e) { + console.warn('[SDKR] cleanup failed: DELETE ' + path + ' — ' + e.message); + } + }; + + // ─── Auth Token ──────────────────────────────────────────── + + var _token = ''; + + function _readToken() { + var env = (T.base || '').replace(/^\//, '') || 'default'; + var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token']; + for (var i = 0; i < keys.length; i++) { + try { + var raw = localStorage.getItem(keys[i]); + if (!raw) continue; + try { + var p = JSON.parse(raw); + if (p) { var t = p.access_token || p.token || p.accessToken || ''; if (t) return t; } + } catch (_) { /* not JSON */ } + if (raw.length > 20 && raw.indexOf('{') !== 0) return raw; + } catch (_) { /* skip */ } + } + return ''; + } + + T.getAuthToken = async function () { + if (!_token) _token = _readToken(); + return _token; + }; + + // ─── Cleanup ─────────────────────────────────────────────── + + T.registerCleanup = function (fn) { T.cleanup.push(fn); }; + + T.runCleanup = async function () { + var fns = T.cleanup.splice(0); + for (var i = fns.length - 1; i >= 0; i--) { + try { await fns[i](); } catch (_) { /* swallow */ } + } + }; + + // ─── Dual-Path Test ──────────────────────────────────────── + // + // sdkFn: async () => result — calls sw.api.* + // rawSpec: { method, path, body? } — ICD-correct HTTP call + // validate: (result) => void — throws if shape is wrong + // + // Returns: { verdict, sdkErr?, rawErr?, result? } + + T.dualTest = async function (sdkFn, rawSpec, validate) { + // 1. Try SDK + var sdkResult, sdkErr; + try { + sdkResult = await sdkFn(); + } catch (e) { + sdkErr = e; + } + + if (!sdkErr) { + // SDK call succeeded — validate shape + try { + if (validate) validate(sdkResult); + return { verdict: 'PASS', result: sdkResult }; + } catch (e) { + // Shape mismatch — might be _unwrap bug. Confirm with raw. + var rawResult2; + try { + rawResult2 = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body); + } catch (_) { /* ignore raw errors in this branch */ } + return { verdict: 'SHAPE_BUG', sdkErr: e.message, sdkResult: sdkResult, rawResult: rawResult2 }; + } + } + + // 2. SDK failed — try raw ICD fetch + if (!rawSpec) { + return { verdict: 'SDK_BUG', sdkErr: sdkErr.message, note: 'no raw spec provided' }; + } + + var rawResult, rawErr; + try { + rawResult = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body); + } catch (e) { + rawErr = e; + } + + if (rawErr) { + // Both failed → ICD/backend issue + return { verdict: 'ICD_BUG', sdkErr: sdkErr.message, rawErr: rawErr.message }; + } + + // Raw succeeded, SDK failed → SDK has a bug + // Optionally validate the raw result too + var rawValid = true; + if (validate) { + try { validate(rawResult); } catch (e) { rawValid = false; } + } + + return { + verdict: 'SDK_BUG', + sdkErr: sdkErr.message, + rawResult: rawResult, + rawValid: rawValid + }; + }; + + // ─── Test Harness ────────────────────────────────────────── + + /** + * Register and run a single test. + * + * @param {string} domain — e.g. 'channels', 'workflows' + * @param {string} group — e.g. 'crud', 'list', 'update' + * @param {string} name — human-readable test name + * @param {object} spec — { sdk, raw, validate } + * sdk: async () => result + * raw: { method: 'GET'|'POST'|..., path: '/channels', body?: {...} } + * validate: (result) => void (throw on failure) + * + * For tests that don't use dual-path (e.g. setup/cleanup), pass a + * plain async function as the 4th argument instead of a spec object. + */ + T.test = async function (domain, group, name, specOrFn) { + if (T.aborted) return; + + var entry = { + domain: domain, group: group, name: name, + verdict: null, sdkErr: null, rawErr: null, note: null, + durationMs: 0 + }; + + var t0 = performance.now(); + + try { + // Skip support — mark as SKIP with reason + if (typeof specOrFn === 'object' && specOrFn.skip) { + entry.verdict = 'SKIP'; + entry.note = specOrFn.skip; + } else if (typeof specOrFn === 'function') { + // Simple test — no dual-path + await specOrFn(); + entry.verdict = 'PASS'; + } else { + // Dual-path test + var r = await T.dualTest(specOrFn.sdk, specOrFn.raw, specOrFn.validate); + entry.verdict = r.verdict; + entry.sdkErr = r.sdkErr || null; + entry.rawErr = r.rawErr || null; + entry.note = r.note || null; + } + } catch (e) { + entry.verdict = 'ERROR'; + entry.sdkErr = e.message; + } + + entry.durationMs = Math.round(performance.now() - t0); + + // Update stats + var v = entry.verdict.toLowerCase().replace('error', 'icd_bug'); + if (T.stats.hasOwnProperty(v)) T.stats[v]++; + + T.results.push(entry); + + // Live update + if (typeof T.onResult === 'function') T.onResult(entry); + }; + + // ─── Domain Registry ─────────────────────────────────────── + + T.domains = {}; + + T.registerDomain = function (name, fn) { + T.domains[name] = fn; + }; + + // ─── Run All ─────────────────────────────────────────────── + + T.runAll = async function (domainFilter) { + T.running = true; + T.aborted = false; + T.results = []; + T.cleanup = []; + T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 }; + + if (typeof T.onStart === 'function') T.onStart(); + + // Provision fixtures (policies, provider, team) before domains + if (typeof T.provisionFixtures === 'function') { + try { await T.provisionFixtures(); } catch (e) { + console.warn('[SDKR] Fixture provisioning failed:', e.message); + } + } + + var names = Object.keys(T.domains).sort(); + if (domainFilter) { + names = names.filter(function (n) { return domainFilter.indexOf(n) !== -1; }); + } + + for (var i = 0; i < names.length; i++) { + if (T.aborted) break; + try { + await T.domains[names[i]](); + } catch (e) { + T.results.push({ + domain: names[i], group: 'setup', name: 'domain crashed', + verdict: 'ERROR', sdkErr: e.message, rawErr: null, note: null, + durationMs: 0 + }); + } + // Run cleanup between domains + await T.runCleanup(); + } + + // Teardown fixtures (revert policies, etc.) + if (typeof T.teardownFixtures === 'function') { + try { await T.teardownFixtures(); } catch (_) {} + } + + T.running = false; + if (typeof T.onComplete === 'function') T.onComplete(); + }; + + T.abort = function () { T.aborted = true; }; + +})(); diff --git a/packages/sdk-test-runner/js/main.js b/packages/sdk-test-runner/js/main.js new file mode 100644 index 0000000..d332e3b --- /dev/null +++ b/packages/sdk-test-runner/js/main.js @@ -0,0 +1,107 @@ +/** + * SDK Test Runner — Entry Point + * + * Load order: + * 1. framework.js — dual-path harness, assertions, raw fetch + * 2. shapes.js — ICD response shapes + * 3. domains/*.js — per-domain test suites (register on T.domains) + * 4. ui.js — render functions + * 5. (this file) — boot + */ +(async function () { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + // ─── Boot SDK ────────────────────────────────────────────── + + try { + var base = window.__BASE__ || ''; + var ver = window.__VERSION__ || '0'; + + if (!window.preact) { + var { h, render } = await import(base + '/js/sw/vendor/preact.module.js'); + var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js'); + var htmModule = await import(base + '/js/sw/vendor/htm.module.js'); + window.preact = { h, render }; + window.hooks = hooksModule; + window.html = htmModule.default.bind(h); + } + + var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver); + await sdk.boot(); + console.log('[SDKR] SDK booted — window.sw ready'); + } catch (e) { + console.warn('[SDKR] SDK boot failed:', e.message); + } + + // ─── Shared Namespace ────────────────────────────────────── + + window.SDKR = { + mount: mount, + manifest: window.__MANIFEST__ || {}, + base: window.__BASE__ || '', + // Populated by framework.js + results: [], + cleanup: [], + stats: {}, + domains: {}, + S: {} + }; + + // ─── Script Loader ───────────────────────────────────────── + + var surfaceId = window.SDKR.manifest.id || 'sdk-test-runner'; + var assetBase = '/surfaces/' + surfaceId + '/js/'; + + if (window.SDKR.base) { + assetBase = window.SDKR.base + assetBase; + } + + var modules = [ + 'framework.js', + 'shapes.js', + 'fixtures.js', + // Domain test suites + 'domains/channels.js', + 'domains/notes.js', + 'domains/projects.js', + 'domains/personas.js', + 'domains/knowledge.js', + 'domains/workflows.js', + 'domains/workspaces.js', + 'domains/tasks.js', + 'domains/misc.js', + 'domains/admin.js', + // UI (must come last) + 'ui.js' + ]; + + var loaded = 0; + + function loadNext() { + if (loaded >= modules.length) { + boot(); + return; + } + var script = document.createElement('script'); + script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now(); + script.onload = function () { loaded++; loadNext(); }; + script.onerror = function () { + console.error('[SDKR] Failed to load: ' + modules[loaded]); + loaded++; loadNext(); + }; + document.body.appendChild(script); + } + + function boot() { + if (typeof window.SDKR.renderShell === 'function') { + window.SDKR.renderShell(); + } else { + mount.innerHTML = '

SDK Test Runner failed to load modules. Check console.

'; + } + } + + loadNext(); +})(); diff --git a/packages/sdk-test-runner/js/shapes.js b/packages/sdk-test-runner/js/shapes.js new file mode 100644 index 0000000..9aa044b --- /dev/null +++ b/packages/sdk-test-runner/js/shapes.js @@ -0,0 +1,112 @@ +/** + * SDK Test Runner — ICD Response Shapes + * + * Mirrors the shapes from the ICD test runner. Each shape is a map + * of field names to type strings. Types: + * string, number, bool, array, object, uuid + * string? / number? / object? = nullable/optional + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.S = {}; + + // ── Core ────────────────────────────────────────────────── + + T.S.channel = { + id: 'string', title: 'string', type: 'string', + created_at: 'string', updated_at: 'string' + }; + T.S.channelFull = { + id: 'string', user_id: 'string', title: 'string', type: 'string', + ai_mode: 'string?', topic: 'string?', description: 'string?', + model: 'string?', provider_config_id: 'string?', + system_prompt: 'string?', is_archived: 'bool', is_pinned: 'bool', + folder: 'string?', folder_id: 'string?', project_id: 'string?', + workspace_id: 'string?', + tags: 'array', created_at: 'string', updated_at: 'string' + }; + T.S.message = { + id: 'string', channel_id: 'string', role: 'string', + content: 'string', created_at: 'string' + }; + T.S.persona = { + id: 'string', name: 'string', scope: 'string', + created_at: 'string', updated_at: 'string' + }; + T.S.note = { + id: 'string', title: 'string', + created_at: 'string', updated_at: 'string' + }; + T.S.project = { + id: 'string', name: 'string', scope: 'string', owner_id: 'string', + is_archived: 'bool', created_at: 'string', updated_at: 'string', + description: 'string?', color: 'string?', icon: 'string?', + team_id: 'string?', workspace_id: 'string?', settings: 'object?', + channel_count: 'number?', kb_count: 'number?', note_count: 'number?' + }; + T.S.kb = { + id: 'string', name: 'string', scope: 'string', + embedding_config: 'object', document_count: 'number', + chunk_count: 'number', total_bytes: 'number', status: 'string', + created_at: 'string', updated_at: 'string' + }; + T.S.workspace = { + id: 'string', name: 'string', owner_type: 'string', + owner_id: 'string', status: 'string', + created_at: 'string', updated_at: 'string' + }; + T.S.folder = { id: 'string', name: 'string', created_at: 'string' }; + T.S.notification = { + id: 'string', type: 'string', title: 'string', + read: 'bool', created_at: 'string' + }; + T.S.memory = { + id: 'string', scope: 'string', owner_id: 'string', + key: 'string', value: 'string', confidence: 'number', + status: 'string', created_at: 'string', updated_at: 'string' + }; + T.S.profile = { + id: 'string', username: 'string', email: 'string', + role: 'string', settings: 'object', created_at: 'string' + }; + T.S.task = { + id: 'string', name: 'string', task_type: 'string', + schedule: 'string', is_active: 'bool', created_at: 'string' + }; + T.S.workflow = { + id: 'string', name: 'string', slug: 'string', + entry_mode: 'string', is_active: 'bool', + created_at: 'string', updated_at: 'string' + }; + T.S.workflowStage = { + id: 'string', workflow_id: 'string', ordinal: 'number', + name: 'string', history_mode: 'string', created_at: 'string', + surface_pkg_id: 'string?' + }; + T.S.team = { id: 'string', name: 'string', created_at: 'string?' }; + T.S.teamMember = { user_id: 'string', role: 'string' }; + T.S.group = { id: 'string', name: 'string' }; + T.S.surface = { id: 'string', title: 'string', source: 'string', enabled: 'bool' }; + T.S.providerConfig = { + id: 'string', name: 'string', provider: 'string', scope: 'string' + }; + T.S.extension = { + id: 'string', title: 'string', type: 'string', version: 'string', + tier: 'string', enabled: 'bool', is_system: 'bool', scope: 'string', + source: 'string', installed_at: 'string', updated_at: 'string' + }; + T.S.modelEnabled = { + id: 'string', model_id: 'string', display_name: 'string', + model_type: 'string', source: 'string', + provider_config_id: 'string', provider_name: 'string', + provider_type: 'string', capabilities: 'object', + scope: 'string', is_persona: 'bool', hidden: 'bool' + }; + T.S.gitCredSummary = { + id: 'string', name: 'string', auth_type: 'string', created_at: 'string' + }; + +})(); diff --git a/packages/sdk-test-runner/js/ui.js b/packages/sdk-test-runner/js/ui.js new file mode 100644 index 0000000..4ec6ecb --- /dev/null +++ b/packages/sdk-test-runner/js/ui.js @@ -0,0 +1,262 @@ +/** + * SDK Test Runner — UI + * + * Renders: summary bar, domain filter, live results table, export. + * Follows ICD runner visual conventions. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + // ─── Verdict Colors ──────────────────────────────────────── + + var COLORS = { + PASS: 'var(--success, #22c55e)', + SDK_BUG: 'var(--warning, #f59e0b)', + ICD_BUG: 'var(--danger, #ef4444)', + SHAPE_BUG: '#a855f7', + SKIP: 'var(--text3, #888)', + ERROR: 'var(--danger, #ef4444)' + }; + + var LABELS = { + PASS: 'PASS', + SDK_BUG: 'SDK BUG', + ICD_BUG: 'ICD BUG', + SHAPE_BUG: 'SHAPE', + SKIP: 'SKIP', + ERROR: 'ERROR' + }; + + // ─── DOM Helpers ────────────────────────────────────────── + + function el(tag, attrs, children) { + var e = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { + if (k === 'className') e.className = attrs[k]; + else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(e.style, attrs[k]); + else if (k.indexOf('on') === 0) e.addEventListener(k.slice(2).toLowerCase(), attrs[k]); + else e.setAttribute(k, attrs[k]); + }); + if (children) { + if (!Array.isArray(children)) children = [children]; + children.forEach(function (c) { + if (typeof c === 'string') e.appendChild(document.createTextNode(c)); + else if (c) e.appendChild(c); + }); + } + return e; + } + + function esc(s) { return String(s || ''); } + + // ─── Shell ──────────────────────────────────────────────── + + T.renderShell = function () { + var mount = T.mount; + mount.innerHTML = ''; + + // Header + var header = el('div', { className: 'sdkr-header' }, [ + el('h1', {}, 'SDK Test Runner'), + el('span', { className: 'sdkr-version' }, 'v' + (T.manifest.version || '?')) + ]); + mount.appendChild(header); + + // Description + mount.appendChild(el('p', { className: 'sdkr-desc' }, + 'Dual-path validation: SDK call → raw ICD fetch. ' + + 'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.')); + + // ── Fixtures Panel ── + var fixturePanel = el('div', { className: 'sdkr-fixtures' }); + + var fixRow = el('div', { className: 'sdkr-fixture-row' }); + fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:')); + + // Provider dropdown + var provSelect = el('select', { className: 'sdkr-select' }); + provSelect.appendChild(el('option', { value: '' }, '— provider —')); + ['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) { + provSelect.appendChild(el('option', { value: p }, p)); + }); + fixRow.appendChild(provSelect); + + // API key input + var keyInput = el('input', { + type: 'password', className: 'sdkr-input', + placeholder: 'API key (optional — enables model tests)' + }); + fixRow.appendChild(keyInput); + + // Fixture status + var fixStatus = el('span', { className: 'sdkr-fixture-status' }, ''); + + fixRow.appendChild(fixStatus); + fixturePanel.appendChild(fixRow); + + var fixNote = el('div', { className: 'sdkr-fixture-note' }, + 'Provide a provider + API key to unlock embedding/model tests. ' + + 'Without a key, those tests are skipped. Key is never stored.'); + fixturePanel.appendChild(fixNote); + + mount.appendChild(fixturePanel); + + // ── Fixture wiring: store user input for provisionFixtures to use ── + T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus }; + + // Controls row + var controlRow = el('div', { className: 'sdkr-controls' }); + + // Domain filter checkboxes + var filterBox = el('div', { className: 'sdkr-filters' }); + filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: ')); + var domainNames = Object.keys(T.domains).sort(); + var domainChecks = {}; + + domainNames.forEach(function (name) { + var label = el('label', { className: 'sdkr-check' }); + var cb = el('input', { type: 'checkbox', checked: 'checked' }); + domainChecks[name] = cb; + label.appendChild(cb); + label.appendChild(document.createTextNode(' ' + name)); + filterBox.appendChild(label); + }); + controlRow.appendChild(filterBox); + + // Buttons + var btnRow = el('div', { className: 'sdkr-btn-row' }); + var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All'); + var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort'); + abortBtn.style.display = 'none'; + var exportBtn = el('button', { className: 'sdkr-btn', onClick: onExport }, 'Export JSON'); + btnRow.appendChild(runBtn); + btnRow.appendChild(abortBtn); + btnRow.appendChild(exportBtn); + controlRow.appendChild(btnRow); + mount.appendChild(controlRow); + + // Summary bar + var summaryBar = el('div', { className: 'sdkr-summary' }); + mount.appendChild(summaryBar); + + // Results table + var tableWrap = el('div', { className: 'sdkr-table-wrap' }); + var table = el('table', { className: 'sdkr-table' }); + var thead = el('thead', {}, [ + el('tr', {}, [ + el('th', {}, '#'), + el('th', {}, 'Domain'), + el('th', {}, 'Group'), + el('th', {}, 'Test'), + el('th', {}, 'Verdict'), + el('th', {}, 'ms'), + el('th', {}, 'Detail') + ]) + ]); + table.appendChild(thead); + var tbody = el('tbody', {}); + table.appendChild(tbody); + tableWrap.appendChild(table); + mount.appendChild(tableWrap); + + // ── Callbacks ── + + function updateSummary() { + var s = T.stats; + var total = T.results.length; + summaryBar.innerHTML = ''; + var items = [ + ['PASS', s.pass], + ['SDK_BUG', s.sdk_bug], + ['ICD_BUG', s.icd_bug], + ['SHAPE_BUG', s.shape_bug], + ['SKIP', s.skip] + ]; + items.forEach(function (item) { + var badge = el('span', { + className: 'sdkr-badge', + style: { + background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)', + color: item[1] > 0 ? '#fff' : 'var(--text3, #888)' + } + }, LABELS[item[0]] + ': ' + item[1]); + summaryBar.appendChild(badge); + }); + summaryBar.appendChild(el('span', { className: 'sdkr-badge', + style: { background: 'var(--bg2, #333)' } + }, 'Total: ' + total)); + } + + T.onResult = function (entry) { + var idx = T.results.length; + var v = entry.verdict; + var detail = ''; + if (entry.sdkErr) detail += 'SDK: ' + esc(entry.sdkErr); + if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr); + if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note); + + var row = el('tr', { className: 'sdkr-row sdkr-row-' + v.toLowerCase() }, [ + el('td', {}, String(idx)), + el('td', {}, esc(entry.domain)), + el('td', {}, esc(entry.group)), + el('td', { className: 'sdkr-test-name' }, esc(entry.name)), + el('td', {}, [ + el('span', { + className: 'sdkr-verdict', + style: { color: COLORS[v] || '#fff' } + }, LABELS[v] || v) + ]), + el('td', { className: 'sdkr-ms' }, String(entry.durationMs)), + el('td', { className: 'sdkr-detail' }, detail) + ]); + tbody.appendChild(row); + updateSummary(); + + // Auto-scroll + row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }; + + T.onStart = function () { + tbody.innerHTML = ''; + runBtn.disabled = true; + runBtn.textContent = 'Running...'; + abortBtn.style.display = ''; + updateSummary(); + }; + + T.onComplete = function () { + runBtn.disabled = false; + runBtn.textContent = 'Run All'; + abortBtn.style.display = 'none'; + updateSummary(); + }; + + function onRun() { + var selected = []; + domainNames.forEach(function (n) { + if (domainChecks[n].checked) selected.push(n); + }); + T.runAll(selected.length === domainNames.length ? null : selected); + } + + function onExport() { + var data = { + timestamp: new Date().toISOString(), + version: T.manifest.version, + stats: T.stats, + results: T.results + }; + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'sdk-test-results-' + Date.now() + '.json'; + a.click(); + URL.revokeObjectURL(a.href); + } + + updateSummary(); + }; + +})(); diff --git a/packages/sdk-test-runner/manifest.json b/packages/sdk-test-runner/manifest.json new file mode 100644 index 0000000..717d5b1 --- /dev/null +++ b/packages/sdk-test-runner/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "sdk-test-runner", + "title": "SDK Test Runner", + "route": "/s/sdk-test-runner", + "auth": "authenticated", + "layout": "single", + "version": "0.37.15.0", + "description": "Dual-path SDK validation: tests every sw.api.* domain method against the ICD. SDK fail + ICD pass = SDK bug. Both fail = ICD bug." +} diff --git a/server/handlers/files.go b/server/handlers/files.go index 89ae753..9109089 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "os" "path/filepath" "strings" "time" @@ -16,6 +17,7 @@ import ( "chat-switchboard/models" "chat-switchboard/storage" "chat-switchboard/store" + "chat-switchboard/workspace" ) // ── Default Limits ───────────────────────── @@ -53,6 +55,7 @@ var allowedMIMETypes = map[string]bool{ type FileHandler struct { stores store.Stores objStore storage.ObjectStore + wfs *workspace.FS // nil if workspace FS not configured extQueue *extraction.Queue // nil if extraction disabled } @@ -60,6 +63,11 @@ func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue} } +// SetWorkspaceFS attaches the workspace filesystem for project file operations. +func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) { + h.wfs = wfs +} + // ── Upload ───────────────────────────────── // POST /api/v1/channels/:id/files // Multipart form: file field "file", returns file metadata. @@ -496,27 +504,92 @@ func sanitizeFilename(name string) string { return name } -// ── Project Files (v0.22.4) ──────────────── +// ── Project Files (v0.22.4, reworked v0.37.17) ───────────── +// Project files route through the Workspace FS subsystem. +// A workspace is auto-created on first file upload (lazy binding). -// UploadToProject handles project-scoped file uploads. +// verifyProjectAccess checks that the user can access the project. +// Returns true if access is granted (admins bypass). +func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool { + if c.GetString("role") == "admin" { + return true + } + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) + ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) + if err != nil || !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) + return false + } + return true +} + +// ensureProjectWorkspace returns the project's workspace, creating one if needed. +func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) { + proj, err := h.stores.Projects.GetByID(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("project not found: %w", err) + } + + // Already has a workspace — load and return it. + if proj.WorkspaceID != nil && *proj.WorkspaceID != "" { + ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err) + } + return ws, nil + } + + // Create workspace for this project. + ws := &models.Workspace{ + OwnerType: models.WorkspaceOwnerProject, + OwnerID: projectID, + Name: proj.Name + " Files", + Status: models.WorkspaceStatusActive, + } + ws.ID = store.NewID() + ws.RootPath = "workspaces/" + ws.ID + + if err := h.stores.Workspaces.Create(ctx, ws); err != nil { + // Race guard: another request may have created it. Re-read the project. + proj2, err2 := h.stores.Projects.GetByID(ctx, projectID) + if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" { + return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID) + } + return nil, fmt.Errorf("failed to create workspace: %w", err) + } + + // Create directory on disk. + if err := h.wfs.CreateDir(ws); err != nil { + log.Printf("warning: workspace mkdir for project %s: %v", projectID, err) + } + + // Bind workspace to project. + h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID}) + + return ws, nil +} + +// UploadToProject handles project-scoped file uploads via workspace FS. // POST /api/v1/projects/:id/files func (h *FileHandler) UploadToProject(c *gin.Context) { - if h.objStore == nil { + if h.wfs == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) return } userID := getUserID(c) projectID := c.Param("id") + ctx := c.Request.Context() - // Verify project access (user owns or is team member; admins bypass) - if c.GetString("role") != "admin" { - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) - if err != nil || !ok { - c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) - return - } + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + ws, err := h.ensureProjectWorkspace(ctx, projectID) + if err != nil { + log.Printf("error: ensure workspace for project %s: %v", projectID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) + return } file, header, err := c.Request.FormFile("file") @@ -531,6 +604,19 @@ func (h *FileHandler) UploadToProject(c *gin.Context) { return } + // Quota check + stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID) + quota := workspace.DefaultMaxBytes + if ws.MaxBytes != nil { + quota = *ws.MaxBytes + } + if stats != nil && stats.TotalBytes+header.Size > quota { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota), + }) + return + } + // Detect content type buf := make([]byte, 512) n, _ := file.Read(buf) @@ -542,70 +628,331 @@ func (h *FileHandler) UploadToProject(c *gin.Context) { contentType = better } - // Store file - storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename)) - if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil { - log.Printf("error: project file upload: %v", err) + filename := sanitizeFilename(header.Filename) + + // Determine upload path (support optional path prefix via query param) + uploadPath := filename + if prefix := c.Query("path"); prefix != "" { + uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename + } + + if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil { + log.Printf("error: project file write: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) return } - att := models.File{ - ChannelID: "", // no channel association - UserID: userID, - ProjectID: &projectID, - Origin: models.FileOriginUserUpload, - Filename: header.Filename, - ContentType: contentType, - SizeBytes: header.Size, - StorageKey: storageKey, - DisplayHint: displayHintFor(contentType), - } - - if err := h.stores.Files.Create(c.Request.Context(), &att); err != nil { - log.Printf("error: project file create: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"}) - return - } - - if h.extQueue != nil { - if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil { - log.Printf("warning: project file extraction enqueue failed: %v", err) - } - } - - c.JSON(http.StatusCreated, att) + c.JSON(http.StatusCreated, gin.H{ + "path": uploadPath, + "filename": filename, + "content_type": contentType, + "size_bytes": header.Size, + }) } -// ListByProject returns all files uploaded to a project. +// ListByProject returns workspace files for a project. // GET /api/v1/projects/:id/files func (h *FileHandler) ListByProject(c *gin.Context) { userID := getUserID(c) projectID := c.Param("id") + ctx := c.Request.Context() - // Admins bypass project ownership/membership checks - if c.GetString("role") != "admin" { - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) - if err != nil || !ok { - c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) - return - } + if !h.verifyProjectAccess(c, projectID, userID) { + return } - files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID) + // Load project to get workspace_id + proj, err := h.stores.Projects.GetByID(ctx, projectID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) + return + } + + // No workspace yet → empty list + if proj.WorkspaceID == nil || *proj.WorkspaceID == "" { + c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0}) + return + } + + ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0}) + return + } + + dirPath := c.DefaultQuery("path", "") + recursive := c.DefaultQuery("recursive", "true") == "true" + + files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive) if err != nil { log.Printf("error: list project files: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { - files = []models.File{} + files = []models.WorkspaceFile{} } c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)}) } +// DownloadProjectFile streams a single file from the project workspace. +// GET /api/v1/projects/:id/files/download?path=... +func (h *FileHandler) DownloadProjectFile(c *gin.Context) { + if h.wfs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + projectID := c.Param("id") + ctx := c.Request.Context() + + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + filePath := c.Query("path") + if filePath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) + return + } + + proj, err := h.stores.Projects.GetByID(ctx, projectID) + if err != nil || proj.WorkspaceID == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) + return + } + + ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) + return + } + + rc, size, err := h.wfs.ReadFile(ctx, ws, filePath) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + defer rc.Close() + + // Detect content type + f, _ := h.wfs.Stat(ctx, ws, filePath) + contentType := "application/octet-stream" + if f != nil && f.ContentType != "" { + contentType = f.ContentType + } + + filename := filepath.Base(filePath) + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Header("Content-Length", fmt.Sprintf("%d", size)) + c.DataFromReader(http.StatusOK, size, contentType, rc, nil) +} + +// DeleteProjectFile deletes a file or directory from the project workspace. +// DELETE /api/v1/projects/:id/files?path=... +func (h *FileHandler) DeleteProjectFile(c *gin.Context) { + if h.wfs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + projectID := c.Param("id") + ctx := c.Request.Context() + + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + filePath := c.Query("path") + if filePath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) + return + } + + proj, err := h.stores.Projects.GetByID(ctx, projectID) + if err != nil || proj.WorkspaceID == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) + return + } + + ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) + return + } + + recursive := c.Query("recursive") == "true" + if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// MkdirProject creates a directory in the project workspace. +// POST /api/v1/projects/:id/files/mkdir +func (h *FileHandler) MkdirProject(c *gin.Context) { + if h.wfs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + projectID := c.Param("id") + ctx := c.Request.Context() + + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + ws, err := h.ensureProjectWorkspace(ctx, projectID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) + return + } + + // Accept path from query param or JSON body + dirPath := c.Query("path") + if dirPath == "" { + var body struct { + Path string `json:"path"` + } + if c.ShouldBindJSON(&body) == nil && body.Path != "" { + dirPath = body.Path + } + } + if dirPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) + return + } + + if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath}) +} + +// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace. +// POST /api/v1/projects/:id/archive/upload +func (h *FileHandler) UploadProjectArchive(c *gin.Context) { + if h.wfs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + projectID := c.Param("id") + ctx := c.Request.Context() + + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + ws, err := h.ensureProjectWorkspace(ctx, projectID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"}) + return + } + defer file.Close() + + 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 (zip extraction needs seekable file) + tmp, err := os.CreateTemp("", "project-archive-*") + 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(ctx, ws, 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}) +} + +// DownloadProjectArchive creates and streams a zip/tar.gz of all project files. +// GET /api/v1/projects/:id/archive/download?format=zip +func (h *FileHandler) DownloadProjectArchive(c *gin.Context) { + if h.wfs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) + return + } + + userID := getUserID(c) + projectID := c.Param("id") + ctx := c.Request.Context() + + if !h.verifyProjectAccess(c, projectID, userID) { + return + } + + proj, err := h.stores.Projects.GetByID(ctx, projectID) + if err != nil || proj.WorkspaceID == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) + return + } + + ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) + 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(ctx, ws, format) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer os.Remove(archivePath) + + ext := "zip" + if format == "tar.gz" || format == "tgz" { + ext = "tar.gz" + } + + filename := fmt.Sprintf("%s.%s", proj.Name, ext) + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.File(archivePath) +} + // extToMIME maps file extensions to MIME types for cases where // http.DetectContentType returns application/octet-stream. var extToMIME = map[string]string{ diff --git a/server/main.go b/server/main.go index 1371b2a..6c3e9ae 100644 --- a/server/main.go +++ b/server/main.go @@ -888,14 +888,18 @@ func main() { protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote) protected.GET("/projects/:id/notes", projectH.ListNotes) - // Project files (v0.22.4) — wired via fileH which is declared later, - // so we use a closure that captures the variable. - protected.POST("/projects/:id/files", func(c *gin.Context) { - handlers.NewFileHandler(stores, objStore, extQueue).UploadToProject(c) - }) - protected.GET("/projects/:id/files", func(c *gin.Context) { - handlers.NewFileHandler(stores, objStore, extQueue).ListByProject(c) - }) + // Project files (v0.22.4, reworked v0.37.17 — workspace-backed) + projFileH := handlers.NewFileHandler(stores, objStore, extQueue) + if wfs != nil { + projFileH.SetWorkspaceFS(wfs) + } + protected.POST("/projects/:id/files", projFileH.UploadToProject) + protected.GET("/projects/:id/files", projFileH.ListByProject) + protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile) + protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile) + protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject) + protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive) + protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive) // Notifications (v0.20.0) notifH := handlers.NewNotificationHandler(stores, hub) diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index ffccc83..6e853c4 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -4142,11 +4142,23 @@ paths: tags: - Projects - Files - summary: List project files + summary: List project files (workspace-backed) + description: Returns workspace files for the project. Auto-creates workspace on first upload. security: - bearerAuth: [] parameters: - $ref: '#/components/parameters/ResourceID' + - name: path + in: query + schema: + type: string + description: Directory path to list (empty = root) + - name: recursive + in: query + schema: + type: string + default: 'true' + description: Include subdirectories responses: '200': description: Project files @@ -4155,10 +4167,23 @@ paths: schema: type: object properties: - data: + files: type: array items: - $ref: '#/components/schemas/Project' + type: object + properties: + id: + type: string + path: + type: string + is_directory: + type: boolean + content_type: + type: string + size_bytes: + type: integer + count: + type: integer '401': $ref: '#/components/responses/Unauthorized' '404': @@ -4167,11 +4192,17 @@ paths: tags: - Projects - Files - summary: Upload file to project + summary: Upload file to project workspace + description: Uploads a file to the project's workspace. Auto-creates workspace on first upload. security: - bearerAuth: [] parameters: - $ref: '#/components/parameters/ResourceID' + - name: path + in: query + schema: + type: string + description: Subdirectory path to upload into requestBody: content: multipart/form-data: @@ -4187,17 +4218,164 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Project' - '400': - $ref: '#/components/responses/BadRequest' + type: object + properties: + path: + type: string + filename: + type: string + content_type: + type: string + size_bytes: + type: integer + '413': + description: File too large or workspace quota exceeded '401': $ref: '#/components/responses/Unauthorized' + delete: + tags: + - Projects + - Files + summary: Delete project file + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + - name: path + in: query + required: true + schema: + type: string + - name: recursive + in: query + schema: + type: string + responses: '200': - description: Success + description: File deleted content: application/json: schema: $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/projects/{id}/files/download: + get: + tags: + - Projects + - Files + summary: Download project file + description: Streams raw file content with Content-Disposition attachment header. + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + - name: path + in: query + required: true + schema: + type: string + responses: + '200': + description: File content + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' + /api/v1/projects/{id}/files/mkdir: + post: + tags: + - Projects + - Files + summary: Create directory in project workspace + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + requestBody: + content: + application/json: + schema: + type: object + properties: + path: + type: string + required: + - path + responses: + '201': + description: Directory created + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/projects/{id}/archive/upload: + post: + tags: + - Projects + - Files + summary: Upload and extract archive into project workspace + description: Accepts .zip or .tar.gz, extracts contents into workspace root. + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Archive extracted + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + files_extracted: + type: integer + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/projects/{id}/archive/download: + get: + tags: + - Projects + - Files + summary: Download all project files as archive + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + - name: format + in: query + schema: + type: string + enum: [zip, tar.gz, tgz] + default: zip + responses: + '200': + description: Archive download + content: + application/zip: + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' /api/v1/workspaces: get: tags: diff --git a/src/css/sw-projects-surface.css b/src/css/sw-projects-surface.css index efe2ff7..f6ac18a 100644 --- a/src/css/sw-projects-surface.css +++ b/src/css/sw-projects-surface.css @@ -647,6 +647,128 @@ border-color: var(--text, #e8e8ed); } +/* ── File Browser (v0.37.17) ──────────────── */ + +.sw-projects__file-browser { + display: flex; + flex-direction: column; + gap: 0; +} + +.sw-projects__file-actions-bar { + display: flex; + gap: 6px; + padding: 4px 0 8px; + border-bottom: 1px solid var(--border, #2e2e35); + margin-bottom: 4px; +} + +.sw-projects__file-action-btn { + background: none; + border: 1px solid var(--border, #2e2e35); + color: var(--text-3, #999); + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.sw-projects__file-action-btn:hover { + color: var(--text-1, #fff); + border-color: var(--text-3, #999); +} + +.sw-projects__file-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + cursor: default; + transition: background 0.1s; +} +.sw-projects__file-row:hover { + background: var(--bg-3, rgba(255,255,255,0.04)); +} + +.sw-projects__file-chevron { + font-size: 8px; + cursor: pointer; + color: var(--text-3, #999); + transition: transform 0.15s; + display: inline-block; + width: 12px; + text-align: center; + flex-shrink: 0; +} +.sw-projects__file-chevron--open { + transform: rotate(90deg); +} + +.sw-projects__file-icon { + flex-shrink: 0; + font-size: 13px; +} + +.sw-projects__file-name { + flex: 1; + font-size: 12px; + color: var(--text-2, #ccc); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.sw-projects__file-name:hover { + color: var(--text-1, #fff); + text-decoration: underline; +} + +.sw-projects__file-meta { + font-size: 11px; + color: var(--text-3, #999); + flex-shrink: 0; +} + +.sw-projects__file-delete { + opacity: 0; + background: none; + border: none; + color: var(--danger, #ef4444); + cursor: pointer; + font-size: 14px; + padding: 0 2px; + flex-shrink: 0; + transition: opacity 0.1s; +} +.sw-projects__file-row:hover .sw-projects__file-delete { + opacity: 0.7; +} +.sw-projects__file-delete:hover { + opacity: 1 !important; +} + +.sw-projects__dropzone { + margin-top: 8px; + padding: 16px; + border: 2px dashed var(--border, #2e2e35); + border-radius: 8px; + text-align: center; + font-size: 12px; + color: var(--text-3, #999); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.sw-projects__dropzone:hover { + border-color: var(--text-3, #999); + color: var(--text-2, #ccc); +} +.sw-projects__dropzone--active { + border-color: var(--accent, #4f8cff); + color: var(--accent, #4f8cff); + background: rgba(79, 140, 255, 0.05); +} + /* ── Responsive ──────────────────────────── */ @media (max-width: 768px) { diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 01b0112..f23d830 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -140,13 +140,17 @@ export function createDomains(restClient) { notes: (id) => rc.get(`/api/v1/projects/${id}/notes`), addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }), removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`), - files: (id) => rc.get(`/api/v1/projects/${id}/files`), - uploadFile: (id, file) => rc.upload(`/api/v1/projects/${id}/files`, file), + files: (id, opts) => rc.get(`/api/v1/projects/${id}/files` + _qs(opts)), + uploadFile: (id, file, path) => rc.upload(`/api/v1/projects/${id}/files` + (path ? _qs({ path }) : ''), file), + deleteFile: (id, path) => rc.del(`/api/v1/projects/${id}/files` + _qs({ path })), + mkdir: (id, path) => rc.post(`/api/v1/projects/${id}/files/mkdir`, { path }), + uploadArchive: (id, file) => rc.upload(`/api/v1/projects/${id}/archive/upload`, file), }, // ── 7. Workspaces ────────────────────── workspaces: { ...crud(rc, '/api/v1/workspaces'), + update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data), files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)), readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })), writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content), @@ -187,11 +191,12 @@ export function createDomains(restClient) { notifications: { list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)), unreadCount: () => rc.get('/api/v1/notifications/unread-count'), - markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}), + markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`, {}), markAllRead: () => rc.post('/api/v1/notifications/mark-all-read', {}), prefs: () => rc.get('/api/v1/notifications/preferences'), setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data), delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`), + del: (id) => rc.del(`/api/v1/notifications/${id}`), }, // ── 12. Extensions ───────────────────── @@ -272,6 +277,8 @@ export function createDomains(restClient) { // ── 15. Workflows ────────────────────── workflows: { ...crud(rc, '/api/v1/workflows'), + update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data), + stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`), instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)), advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data), reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data), @@ -294,8 +301,8 @@ export function createDomains(restClient) { tasks: { ...crud(rc, '/api/v1/tasks'), runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)), - start: (id, data) => rc.post(`/api/v1/tasks/${id}/start`, data || {}), - stop: (id) => rc.post(`/api/v1/tasks/${id}/stop`, {}), + start: (id, data) => rc.post(`/api/v1/tasks/${id}/run`, data || {}), + stop: (id) => rc.post(`/api/v1/tasks/${id}/kill`, {}), }, // ── 17. Surfaces ─────────────────────── @@ -522,13 +529,9 @@ export function createDomains(restClient) { del: (id) => rc.del(`/api/v1/folders/${id}`), }, - notifications: { - list: (params) => rc.get('/api/v1/notifications', params), - unreadCount: () => rc.get('/api/v1/notifications/unread-count'), - markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`), - markAllRead: () => rc.post('/api/v1/notifications/mark-all-read'), - del: (id) => rc.del(`/api/v1/notifications/${id}`), - }, + // NOTE: notifications is defined in the main domain block above (§11). + // The duplicate here was overwriting prefs/setPref/delPref methods. + // Removed — see line ~190 for the canonical definition. files: { get: (id) => rc.get(`/api/v1/files/${id}`), diff --git a/src/js/sw/surfaces/projects/detail.js b/src/js/sw/surfaces/projects/detail.js index fa431f6..69141db 100644 --- a/src/js/sw/surfaces/projects/detail.js +++ b/src/js/sw/surfaces/projects/detail.js @@ -205,14 +205,35 @@ export function ProjectDetail({ project, onBack, onUpdate }) { catch (e) { sw.toast(e.message, 'error'); } } + // ── File state ── + const [expandedDirs, setExpandedDirs] = useState({}); + const [uploading, setUploading] = useState(''); + // ── File upload ── + const ARCHIVE_EXTS = ['.zip', '.tar.gz', '.tgz']; + async function uploadFiles(files) { + const total = files.length; + let done = 0; + setUploading(`Uploading 0 of ${total}\u2026`); for (const file of files) { try { - await sw.api.projects.uploadFile(project.id, file); - sw.toast(`Uploaded ${file.name}`, 'success'); - } catch (e) { sw.toast(e.message, 'error'); } + const isArchive = ARCHIVE_EXTS.some(e => file.name.toLowerCase().endsWith(e)); + if (isArchive && await sw.confirm(`Extract "${file.name}" contents into the project?`)) { + const r = await sw.api.projects.uploadArchive(project.id, file); + sw.toast(`Extracted ${r?.files_extracted || 0} files`, 'success'); + } else { + await sw.api.projects.uploadFile(project.id, file); + sw.toast(`Uploaded ${file.name}`, 'success'); + } + } catch (e) { + const msg = e.status === 413 ? 'Storage quota exceeded' : e.message; + sw.toast(`${file.name}: ${msg}`, 'error'); + } + done++; + setUploading(`Uploading ${done} of ${total}\u2026`); } + setUploading(''); loadAssoc(); } @@ -230,6 +251,127 @@ export function ProjectDetail({ project, onBack, onUpdate }) { if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); } + // ── File actions ── + async function downloadFile(path) { + try { + const token = sw.auth?.token?.(); + const base = window.__BASE__ || ''; + const url = `${base}/api/v1/projects/${project.id}/files/download?path=${encodeURIComponent(path)}`; + const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (!res.ok) throw new Error('Download failed'); + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = path.split('/').pop() || 'download'; + a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function deleteFile(path) { + const name = path.split('/').pop() || path; + if (!await sw.confirm(`Delete "${name}"?`, true)) return; + try { + await sw.api.projects.deleteFile(project.id, path); + sw.toast('Deleted', 'success'); + loadAssoc(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function createFolder() { + const name = await sw.prompt('Folder name:'); + if (!name) return; + try { + await sw.api.projects.mkdir(project.id, name); + sw.toast(`Created "${name}"`, 'success'); + loadAssoc(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function downloadAll() { + try { + const token = sw.auth?.token?.(); + const base = window.__BASE__ || ''; + const url = `${base}/api/v1/projects/${project.id}/archive/download?format=zip`; + const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (!res.ok) throw new Error('Download failed'); + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${project.name || 'project'}.zip`; + a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── File tree builder ── + function buildTree(files) { + if (!files || !files.length) return []; + // Sort: directories first, then alpha + const sorted = [...files].sort((a, b) => { + if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1; + return (a.path || '').localeCompare(b.path || ''); + }); + // Group into top-level entries (split by first path segment) + const root = []; + const dirMap = {}; + for (const f of sorted) { + const parts = (f.path || f.filename || '').split('/').filter(Boolean); + if (parts.length <= 1) { + root.push({ ...f, name: parts[0] || f.filename, children: f.is_directory ? [] : null, depth: 0 }); + if (f.is_directory) dirMap[f.path] = root[root.length - 1]; + } else { + // Find parent dir in root + const parentPath = parts.slice(0, -1).join('/'); + const parent = dirMap[parentPath]; + const entry = { ...f, name: parts[parts.length - 1], children: f.is_directory ? [] : null, depth: parts.length - 1 }; + if (parent) { + parent.children.push(entry); + } else { + root.push(entry); + } + if (f.is_directory) dirMap[f.path] = entry; + } + } + return root; + } + + function fileIcon(f) { + if (f.is_directory) return '\uD83D\uDCC1'; + const ct = f.content_type || ''; + if (ct.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; + if (ct === 'application/pdf') return '\uD83D\uDCC4'; + if (ct.startsWith('text/')) return '\uD83D\uDCC3'; + return '\uD83D\uDCCE'; + } + + function renderFileTree(entries) { + if (!entries || !entries.length) return null; + return entries.map(f => { + const isDir = f.is_directory; + const expanded = expandedDirs[f.path]; + const indent = (f.depth || 0) * 16; + return html`
+
+ ${isDir && html` setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6`} + ${fileIcon(f)} + setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] })) + : () => downloadFile(f.path)} + title=${isDir ? 'Toggle folder' : 'Click to download'}> + ${f.name} + + ${!isDir && html`${formatBytes(f.size_bytes)}`} + ${isDir && f.children && html`${f.children.length} items`} + +
+ ${isDir && expanded && f.children && renderFileTree(f.children)} +
`; + }); + } + // ── New conversation ── async function newConversation() { try { @@ -356,19 +498,28 @@ export function ProjectDetail({ project, onBack, onUpdate }) { ${renderPicker('notes', 'No notes available.')} `, () => openPicker('notes'))} - ${renderSection('files', 'Files', assoc.files.length, html` - ${assoc.files.map(f => html` -
- ${f.filename || f.name} - ${formatBytes(f.size_bytes || f.size)} -
- `)} + ${renderSection('files', 'Files', assoc.files.filter(f => !f.is_directory).length, html` +
+ ${assoc.files.length > 0 && html` +
+ + +
+ `} + ${renderFileTree(buildTree(assoc.files))} +
{ e.preventDefault(); setDragActive(true); }} onDragLeave=${() => setDragActive(false)} onDrop=${handleDrop}> - ${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'} + ${uploading ? uploading + : dragActive ? 'Drop files here' + : assoc.files.length === 0 + ? 'Add PDFs, documents, or other text to reference in this project.' + : 'Drop files or click to upload more'}
`, handleUploadClick)} `;