Changeset 0.37.17 (#229)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 16:50:00 +00:00
committed by xcaliber
parent e0687d2ea6
commit 96a4f16bc5
27 changed files with 4107 additions and 107 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.
---

View File

@@ -46,10 +46,18 @@ v0.9.xv0.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.514 Surfaces ✅ │
│ v0.37.1518 Surfaces │
│ v0.37.3 SDK
│ v0.37.4 Shell
│ v0.37.516 Surfaces ✅ │
│ v0.37.1719 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.29v0.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 510 teams, ~50 users, 100+ anonymous visitors

View File

@@ -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); }

View File

@@ -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'); }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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.
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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; }
});
});
})();

View File

@@ -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');
};
})();

View File

@@ -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; };
})();

View File

@@ -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 = '<p style="color:var(--danger);padding:24px;">SDK Test Runner failed to load modules. Check console.</p>';
}
}
loadNext();
})();

View File

@@ -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'
};
})();

View File

@@ -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();
};
})();

View File

@@ -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."
}

View File

@@ -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"})
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),
c.JSON(http.StatusCreated, gin.H{
"path": uploadPath,
"filename": filename,
"content_type": contentType,
"size_bytes": header.Size,
})
}
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)
}
// 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"})
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
// 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
}
files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID)
// 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{

View File

@@ -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)

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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}`),

View File

@@ -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 {
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) { sw.toast(e.message, 'error'); }
}
} 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`<div key=${f.path || f.name}>
<div class="sw-projects__file-row" style=${'padding-left:' + (8 + indent) + 'px'}>
${isDir && html`<span class=${'sw-projects__file-chevron' + (expanded ? ' sw-projects__file-chevron--open' : '')}
onClick=${() => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6</span>`}
<span class="sw-projects__file-icon">${fileIcon(f)}</span>
<span class="sw-projects__file-name"
onClick=${isDir
? () => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))
: () => downloadFile(f.path)}
title=${isDir ? 'Toggle folder' : 'Click to download'}>
${f.name}
</span>
${!isDir && html`<span class="sw-projects__file-meta">${formatBytes(f.size_bytes)}</span>`}
${isDir && f.children && html`<span class="sw-projects__file-meta">${f.children.length} items</span>`}
<button class="sw-projects__file-delete" onClick=${() => deleteFile(f.path)} title="Delete">\u00D7</button>
</div>
${isDir && expanded && f.children && renderFileTree(f.children)}
</div>`;
});
}
// ── 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`
<div key=${f.id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
${renderSection('files', 'Files', assoc.files.filter(f => !f.is_directory).length, html`
<div class="sw-projects__file-browser">
${assoc.files.length > 0 && html`
<div class="sw-projects__file-actions-bar">
<button class="sw-projects__file-action-btn" onClick=${createFolder}
title="New folder">\uD83D\uDCC1 New folder</button>
<button class="sw-projects__file-action-btn" onClick=${downloadAll}
title="Download all as ZIP">\u2B07 Download all</button>
</div>
`}
${renderFileTree(buildTree(assoc.files))}
</div>
`)}
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
onClick=${handleUploadClick}
onDragOver=${e => { 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'}
</div>
`, handleUploadClick)}
</div>`;