Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33278d0d69 | |||
| 414b2290ce | |||
| b0e9dd7f80 | |||
| 42b864376c | |||
| ac7286f83b | |||
| 75d7abc089 | |||
| 6b9ce92103 | |||
| 0661e1d768 | |||
| 0cae963480 | |||
| 983d761bbe | |||
| d03dfe502f | |||
| 5ad6d77c56 | |||
| 98fd3eb3e6 | |||
| 3c403dd884 | |||
| 190905b3e6 | |||
| 00ef970163 | |||
| 435f972ded | |||
| 694779fac6 | |||
| 3b74774077 | |||
| c2d52f50c5 | |||
| f06c6c954b | |||
| a9cf71b76d | |||
| 3cdfdcf943 | |||
| e4f0bdbd36 | |||
| e02b13dc12 | |||
| 5e830c04de | |||
| a7e38bc72a | |||
| 32e4d8725c | |||
| d6c7b21713 | |||
| 829caa3b20 | |||
| e916ed41ea | |||
| 1236220302 | |||
| e7d1b53ebf | |||
| ff19a1b4d3 | |||
| d9802df2af | |||
| c9b9e68c18 | |||
| 3af62a9cc5 | |||
| 221ae94f4f | |||
| 786bc92768 | |||
| ca3f845c34 | |||
| 617d81e7d4 |
@@ -1,16 +1,18 @@
|
||||
# .gitea/workflows/ci.yaml
|
||||
# ============================================
|
||||
# Armature - CI/CD Pipeline (v0.17.3)
|
||||
# Armature - CI/CD Pipeline (v0.18.0)
|
||||
# ============================================
|
||||
# Single unified image (Go backend + nginx frontend).
|
||||
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
|
||||
#
|
||||
# Pipeline:
|
||||
# 0. Detect changes (path-based gating for all downstream jobs)
|
||||
# 1a. Frontend tests — skipped if only BE/docs changed
|
||||
# 1a. Frontend tests — skipped if only BE/docs/packages changed
|
||||
# 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled)
|
||||
# 1c. Go test (PG) — PG store + handlers against Postgres (race-enabled)
|
||||
# 2. Build + Deploy — skipped if docs-only change
|
||||
# 1d. Test runners — re-enabled v0.7.5 (any code change triggers)
|
||||
# 1e. E2E smoke — Playwright navigation smoke test against every surface
|
||||
# 2. Build + Deploy — always runs (docs are served in-app)
|
||||
#
|
||||
# Test coverage mapping (no package tested by zero jobs):
|
||||
# Unit packages (auto-discovered) → test-sqlite (race)
|
||||
@@ -23,9 +25,11 @@
|
||||
# Path gating rules:
|
||||
# src/, src/editor/ → frontend tests
|
||||
# server/, scripts/db-* → backend tests (PG + SQLite)
|
||||
# packages/ → test-runners + e2e-smoke
|
||||
# Dockerfile*, k8s/, .gitea/ → all tests (infra change)
|
||||
# docs/, *.md → skip all tests + deploy
|
||||
# VERSION, scripts/* → frontend + backend tests
|
||||
# ci/ → infra (CI scripts)
|
||||
# docs/, *.md → build-and-deploy only (docs served in-app)
|
||||
# VERSION, scripts/* → build-and-deploy only (no tests)
|
||||
# Tags (v*) → always full pipeline
|
||||
#
|
||||
# Deployment mapping (single domain, path-based):
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
outputs:
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
packages: ${{ steps.filter.outputs.packages }}
|
||||
infra: ${{ steps.filter.outputs.infra }}
|
||||
docs_only: ${{ steps.filter.outputs.docs_only }}
|
||||
steps:
|
||||
@@ -137,7 +142,7 @@ jobs:
|
||||
echo "${CHANGED}" | sed 's/^/ /'
|
||||
|
||||
# Classify
|
||||
FE=false; BE=false; INFRA=false; DOCS=false; OTHER=false
|
||||
FE=false; BE=false; PKG=false; INFRA=false; DOCS=false; OTHER=false
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
case "$file" in
|
||||
@@ -145,19 +150,23 @@ jobs:
|
||||
FE=true ;;
|
||||
server/*|scripts/db-*)
|
||||
BE=true ;;
|
||||
packages/*)
|
||||
PKG=true ;;
|
||||
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
|
||||
INFRA=true ;;
|
||||
docs/*|*.md|CHANGELOG.md|LICENSE)
|
||||
DOCS=true ;;
|
||||
VERSION|scripts/*)
|
||||
FE=true; BE=true ;;
|
||||
DOCS=true ;; # deploy-only — scripts/db-* already matched as BE above
|
||||
ci/*)
|
||||
INFRA=true ;;
|
||||
*)
|
||||
OTHER=true ;;
|
||||
esac
|
||||
done <<< "${CHANGED}"
|
||||
|
||||
# Docs-only: only docs changed, nothing else
|
||||
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
|
||||
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$PKG" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
|
||||
DOCS_ONLY=true
|
||||
else
|
||||
DOCS_ONLY=false
|
||||
@@ -165,6 +174,7 @@ jobs:
|
||||
|
||||
echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
|
||||
echo "backend=${BE}" >> "$GITHUB_OUTPUT"
|
||||
echo "packages=${PKG}" >> "$GITHUB_OUTPUT"
|
||||
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
|
||||
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -172,6 +182,7 @@ jobs:
|
||||
echo "━━━ Change Detection ━━━"
|
||||
echo " frontend: ${FE}"
|
||||
echo " backend: ${BE}"
|
||||
echo " packages: ${PKG}"
|
||||
echo " infra: ${INFRA}"
|
||||
echo " docs_only: ${DOCS_ONLY}"
|
||||
|
||||
@@ -366,20 +377,90 @@ jobs:
|
||||
psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres
|
||||
echo "✓ Dropped CI test database"
|
||||
|
||||
# ── Stage 1d: Surface Test Runners ──────────
|
||||
# Boots the server in Docker, runs all installed test-runner packages
|
||||
# via Playwright, and asserts zero failures.
|
||||
# See: docker-compose.ci.yml, ci/surface-test-driver.js
|
||||
#
|
||||
# Runs when: backend, frontend, or packages changed (runners test all tiers).
|
||||
# Skipped when: only docs changed.
|
||||
test-runners:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-changes]
|
||||
# DISABLED: Playwright auth bypass not working in Docker (v0.7.5).
|
||||
# Re-enable once headless cookie injection is solved.
|
||||
if: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run surface tests (compose)
|
||||
env:
|
||||
BUNDLED_PACKAGES: '*'
|
||||
ARMATURE_ADMIN_USERNAME: admin
|
||||
ARMATURE_ADMIN_PASSWORD: admin
|
||||
ARMATURE_ADMIN_EMAIL: admin@test.local
|
||||
run: |
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
|
||||
--abort-on-container-exit \
|
||||
--exit-code-from test-runner
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
|
||||
|
||||
# ── Stage 1e: E2E Smoke Test ───────────────
|
||||
# Boots the server in Docker, runs Playwright navigation smoke
|
||||
# test against every surface. Asserts topbar, no JS errors.
|
||||
# Screenshots saved as artifacts on failure.
|
||||
# See: docker-compose.ci.yml (e2e-smoke service), ci/e2e-smoke-driver.js
|
||||
e2e-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-changes]
|
||||
# DISABLED: Playwright auth bypass not working in Docker (v0.7.5).
|
||||
# Re-enable once headless cookie injection is solved.
|
||||
if: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run E2E smoke tests (compose)
|
||||
env:
|
||||
BUNDLED_PACKAGES: '*'
|
||||
ARMATURE_ADMIN_USERNAME: admin
|
||||
ARMATURE_ADMIN_PASSWORD: admin
|
||||
ARMATURE_ADMIN_EMAIL: admin@test.local
|
||||
run: |
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
|
||||
--abort-on-container-exit \
|
||||
--exit-code-from e2e-smoke
|
||||
|
||||
- name: Collect screenshots
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: /tmp/e2e-screenshots/
|
||||
retention-days: 7
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
|
||||
|
||||
# ── Stage 2: Build, Database, Deploy ─────────
|
||||
#
|
||||
# Depends on all test jobs. Skipped jobs (due to path gating)
|
||||
# are treated as successful — no blocking.
|
||||
#
|
||||
# Skipped entirely for docs-only changes (nothing to build).
|
||||
# Always runs — docs are served in-app by the Docs surface.
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite]
|
||||
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
|
||||
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite, test-runners, e2e-smoke]
|
||||
# Run unless: a needed job failed or the workflow was cancelled.
|
||||
# Skipped test jobs (path-gated) are fine — they don't block.
|
||||
# Always deploys — docs are served in-app, VERSION needs a build.
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.detect-changes.outputs.docs_only != 'true'
|
||||
!cancelled() && !failure()
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -413,6 +494,7 @@ jobs:
|
||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||
echo "BUNDLED_PACKAGES=*" >> "$GITHUB_OUTPUT"
|
||||
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
@@ -429,6 +511,7 @@ jobs:
|
||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
|
||||
echo "BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules" >> "$GITHUB_OUTPUT"
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
@@ -444,6 +527,7 @@ jobs:
|
||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
|
||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -670,6 +754,7 @@ jobs:
|
||||
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
||||
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
|
||||
run: |
|
||||
# Render PVC first (must exist before backend references it)
|
||||
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||
|
||||
1448
CHANGELOG.md
442
ROADMAP.md
@@ -1,170 +1,268 @@
|
||||
# Armature — Roadmap
|
||||
|
||||
## Current: v0.6.7 — Native mTLS
|
||||
|
||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||
is an extension.
|
||||
|
||||
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
||||
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
||||
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
|
||||
Audit log · Notifications · Scheduled tasks
|
||||
|
||||
**Completed history:** v0.2.x–v0.5.x fully documented in `CHANGELOG.md`.
|
||||
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
|
||||
workflow engine (multi-stage, team roles, signoff gate, public entry, SLA),
|
||||
package distribution, Notes surface (CM6, folders, tags, backlinks, graph),
|
||||
realtime primitive, Chat surface (chat-core library + surface + polish),
|
||||
upgrade test harness, cluster registry + HA.
|
||||
Audit log · Notifications · Scheduled tasks · Cluster registry + HA ·
|
||||
Extension composability (slots/contributes/cross-package calls)
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0 — MVP
|
||||
## Completed — v0.6.x through v0.8.x
|
||||
|
||||
Extension, communication, and operations tracks converge. First
|
||||
externally usable release.
|
||||
All completed work is documented in `CHANGELOG.md`.
|
||||
|
||||
Design docs: `docs/DESIGN-cluster-registry.md` — PG-backed cluster registry and self-assembling mesh.
|
||||
### v0.6.x — MVP + Hardening
|
||||
|
||||
### v0.6.0 — Cluster Registry + HA
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.6.0 | Cluster Registry + HA |
|
||||
| v0.6.1 | Backup/Restore + Docs |
|
||||
| v0.6.2 | Docs Polish + Dynamic OpenAPI |
|
||||
| v0.6.3 | Dead Code Sweep + Registry Fix |
|
||||
| v0.6.4 | Admin Health/Metrics + Cluster Merge |
|
||||
| v0.6.5 | Renderer Pipeline + Docs Rewrite |
|
||||
| v0.6.6 | Final Hardening |
|
||||
| v0.6.7 | Native mTLS |
|
||||
| v0.6.8 | Rebrand + Cookie Fix |
|
||||
| v0.6.9 | Session Lifetime Config |
|
||||
| v0.6.10 | Viewport Foundation |
|
||||
| v0.6.11 | CSS Deduplication |
|
||||
| v0.6.12 | Extension CSS Isolation |
|
||||
| v0.6.13 | Responsive & Spacing |
|
||||
| v0.6.14 | Visual Polish |
|
||||
| v0.6.15 | User Display Audit |
|
||||
| v0.6.16 | Usability Survey Gate |
|
||||
| v0.6.17 | Bug Fixes & Welcome |
|
||||
| v0.6.18 | CI Bundle Wiring |
|
||||
|
||||
PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/NOTIFY` replaces etcd/Consul/Redis for homelab-to-small-team scale.
|
||||
### v0.7.x — Test Infrastructure + Quality Gate
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. |
|
||||
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. |
|
||||
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). |
|
||||
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. |
|
||||
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. |
|
||||
| LISTEN/NOTIFY routing | ✅ | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). |
|
||||
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. |
|
||||
| Admin cluster dashboard | ✅ | `cluster-dashboard` surface package renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. Auto-refresh every 10s. |
|
||||
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. |
|
||||
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). |
|
||||
| Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. |
|
||||
| Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. |
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.7.0 | Shell Contract + Surface Audit + Rebrand |
|
||||
| v0.7.1 | Surface Runner Framework |
|
||||
| v0.7.2 | Package Runners + CI Gate |
|
||||
| v0.7.3 | Extension Shell Migration |
|
||||
| v0.7.4 | Documentation + Surface Work |
|
||||
| v0.7.5 | Headless E2E + CI Gate |
|
||||
| v0.7.6 | Code Hygiene + Test Coverage |
|
||||
| v0.7.7 | API Tokens + Extension Permissions |
|
||||
| v0.7.8 | Bug Fixes & Admin Gaps |
|
||||
| v0.7.9 | Workflow Independence Audit |
|
||||
| v0.7.10 | Workflow Handoff + Assignment UI |
|
||||
| v0.7.11 | Query & HTTP Ergonomics |
|
||||
| v0.7.12 | Concurrent Execution Primitive |
|
||||
|
||||
### v0.6.1 — Backup/Restore + Documentation
|
||||
### v0.8.x — Storage Primitives + Composability (Kernel Complete)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Backup handler | ✅ | `POST /api/v1/admin/backup` streams `.swb` ZIP (JSONL core + ext_data tables + package assets). `POST /api/v1/admin/restore` wipes DB and restores from archive. Dialect-neutral (SQLite + Postgres). |
|
||||
| Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
|
||||
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
|
||||
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
|
||||
| Docs surface | ✅ | Builtin surface at `/docs/:section`. Sidebar navigation, client-side markdown renderer. 5 new docs: Getting Started, Extension Guide, API Reference, Deployment, Package Format. |
|
||||
| Tests | ✅ | 6 handler tests (basic backup, ext_data backup, round-trip restore, schema mismatch, dump/restore table, list empty). E2E script `ci/e2e-backup-test.sh`. |
|
||||
|
||||
### v0.6.2 — Docs Polish + Dynamic OpenAPI
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Dark mode fix | ✅ | Added `--bg-code` to CSS variables (dark + light). Replaced all hardcoded light-mode fallbacks in docs CSS with theme-aware variables. Table styling, code blocks, nav items all readable in dark mode. |
|
||||
| Loading & error handling | ✅ | Replaced borrowed `settings-placeholder` with docs-specific pulse animation. Added error state with retry button for failed doc list fetch. |
|
||||
| Topbar navigation | ✅ | Imported `Topbar` + `UserMenu` into docs surface. Users can now navigate to other surfaces via the avatar menu. |
|
||||
| Docs icon + menu entry | ✅ | Added `📖 Docs` entry to UserMenu standard items. Docs accessible from any surface's user menu. |
|
||||
| `api_schema` manifest field | ✅ | Optional `api_schema` array in `manifest.json`. Parsed lazily by spec builder. Malformed entries logged and skipped — never blocks extension loading. |
|
||||
| OpenAPI spec builder | ✅ | `BuildOpenAPISpec()` merges static kernel spec with extension routes. Tier 1: auto-generated stubs for all `api_routes`. Tier 2: `api_schema` replaces stubs with rich path items (params, body, response). |
|
||||
| Dynamic spec endpoint | ✅ | `GET /api/docs/openapi.json` serves merged spec. Swagger UI updated to use JSON endpoint. Static YAML preserved for backward compat. |
|
||||
| Tests | ✅ | 7 new handler tests: zero extensions, stubs, rich schema, multi-extension, malformed schema, disabled exclusion, required fields. |
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.8.0 | `files` Module |
|
||||
| v0.8.1 | `workspace` Module |
|
||||
| v0.8.2 | Capability Negotiation |
|
||||
| v0.8.3 | Vector Column Type |
|
||||
| v0.8.4 | Documentation Refresh + Surface Sizing Fix |
|
||||
| v0.8.5 | Extension Composability |
|
||||
|
||||
---
|
||||
|
||||
## v0.6.x — Hardening
|
||||
### v0.9.x — Multi-Surface Packages + Workflow Redesign
|
||||
|
||||
Closes every audit finding before the public release. No new features — only correctness, dead code elimination, and architectural cleanup. Sequence is fixed: each version is a gate for the next.
|
||||
|
||||
### v0.6.3 — Dead Code Sweep + Registry Fix
|
||||
|
||||
Pure cleanup. No behavior changes except fixing the broken registry install flow.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Fix registry install | ✅ | SDK sends `{ url }`, handler expects `{ download_url }`. Fix `api-domains.js` to send `{ download_url: url }`. Every Install click currently returns 400. |
|
||||
| Registry settings UI | ✅ | Add "Package Registry" section to admin settings with URL input field. Only way to configure registry today is a raw `PUT /api/v1/admin/settings/package_registry` — no user will find it. |
|
||||
| Registry tooling + docs | ✅ | `scripts/generate-registry.sh` scans a directory of `.pkg` files and emits registry JSON. `docs/PACKAGE-REGISTRY.md` documents the format. |
|
||||
| Delete dead kernel Go | ✅ | `store/interfaces.go:178–183` — orphaned ChannelListFilter comments. `pages/pages.go:922–930` — `roleFilterType()` + template registration (chat vestige, maps nonexistent roles). `main.go:67` — orphaned provider-type comment. |
|
||||
| Delete dead vendor JS | ✅ | `vendor/marked.min.js` and `vendor/purify.min.js` — 62KB, zero production imports. Only referenced in test helpers. |
|
||||
| Delete `dev.html` | ✅ | 676 lines, not imported by anything. Dead. |
|
||||
| Remove `dashboard` from default bundle | ✅ | Requires `legacy-sdk` (doesn't exist), auto-installs as dormant on fresh installs. Confusing. Remove from `defaultBundledPackages`. |
|
||||
| Remove `hello-dashboard` | ✅ | Proof-of-concept from early development. Move to `examples/` or delete. |
|
||||
| Strip stale version comments | ✅ | 60+ files had legacy version annotations (`// v0.29.x:`, `// v0.33.x:`). Single sed pass. |
|
||||
| Narrow default bundle | ✅ | Default: `notes`, `chat`, `chat-core`, `mermaid-renderer`, `schedules`. Everything else available via registry or `BUNDLED_PACKAGES=*`. |
|
||||
|
||||
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
|
||||
|
||||
Structural move: cluster dashboard becomes an Admin tab. Better home for health/metrics — shared context with other admin panels, no separate nav entry.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| "Health / Metrics" admin tab | ✅ | New tab in Admin surface. DB-agnostic metrics for all deployments. Cluster cards conditional on PG + multi-node detection. |
|
||||
| Runtime metrics | ✅ | Per-node: goroutines, heap alloc/sys, stack in use, GC cycles, last GC pause, GC CPU %, uptime, WebSocket clients, extensions loaded, open FDs. |
|
||||
| DB pool metrics | ✅ | All deployments: DB latency (`SELECT 1` round-trip), pool active/idle/max, wait count, wait duration. PG-only: table bloat (`n_dead_tup`), active backends (`pg_stat_activity`). |
|
||||
| Cluster metrics | ✅ | PG multi-node only: cluster size, peer list with endpoint + uptime, heartbeat age per node, event bus publish/deliver rates. |
|
||||
| Extension runtime metrics | ✅ | Starlark exec/min, errors/min, avg duration, HTTP outbound requests/min, trigger fires/min, schedule overruns. |
|
||||
| Fatten heartbeat payload | ✅ | Heartbeat JSONB carries full metric set. `GET /api/v1/admin/metrics` for single-node SQLite fallback (same shape). |
|
||||
| Retire `cluster-dashboard` | ✅ | Remove package once Admin Health tab ships. Update `defaultBundledPackages`. |
|
||||
| Fix block renderer `requires` | ✅ | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` all have `"requires": ["chat"]`. These are content renderers, not chat features. Remove constraint — they should activate without chat. |
|
||||
| Health endpoint consolidation | ✅ | `/health` and `/api/v1/health` return near-identical JSON. Merge or clearly differentiate with docs. |
|
||||
|
||||
### v0.6.5 — Renderer Pipeline + Docs Rewrite
|
||||
|
||||
Most complex sub-version. Lifts block rendering to a kernel SDK primitive so all surfaces share it, then rewrites the docs for an external audience.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| SDK renderer primitive | ✅ | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. |
|
||||
| Notes hooks SDK renderer pipeline | ✅ | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. |
|
||||
| Docs hooks SDK renderer pipeline | ✅ | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
|
||||
| Unify markdown renderer | ✅ | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
|
||||
| Sanitize HTML output | ✅ | DOMPurify wired as default post-render step in `sw.markdown`. SVG-safe config allows mermaid output. Notes sanitizes; Docs opts out (system-authored). |
|
||||
| Mermaid/KaTeX/CSV/Diff work everywhere | ✅ | Browser extension loader injects renderer scripts into all pages. Extensions register via `sw:ready` event. All four render in Notes, Docs, and Chat. |
|
||||
| Docs rewrite for external audience | ✅ | All fork references removed from docs, CHANGELOG, ROADMAP. DESIGN-WORKFLOW-REDESIGN-0.2.6.md replaced with DESIGN-WORKFLOWS.md. |
|
||||
| Add Mermaid architecture diagrams | ✅ | Six diagrams in ARCHITECTURE.md: system overview, request flow, extension lifecycle, realtime events, settings cascade, cluster topology. |
|
||||
| Surface alias decision | ✅ | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. |
|
||||
| `CONTRIBUTING.md` + tutorial | ✅ | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. |
|
||||
|
||||
### v0.6.7 — Native mTLS
|
||||
|
||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployments where every connection (client→server, node→node) is mTLS. Design: `docs/DESIGN-native-mtls.md`.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. |
|
||||
| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
|
||||
| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go` → `mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. |
|
||||
| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. |
|
||||
| `armature-ca.sh` | ✅ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name <n> --san <addrs>` (365d) · `issue-user --cn <name>` (90d). All output PEM. |
|
||||
| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. |
|
||||
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
|
||||
|
||||
### v0.6.6 — Final Hardening
|
||||
|
||||
Final pass before public release. Security, correctness, and developer experience.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Extension dependency auto-activation | ✅ | Installing a package with unmet `depends`/`requires` auto-installs dependencies from the bundled set. If not bundled: clear error listing what's missing. |
|
||||
| `ValidateManifest()` gate | ✅ | Single `ValidateManifest()` function in `package_validate.go`. Called at install time (both upload and bundled). 12 unit tests. |
|
||||
| Package signing hook | ✅ | Optional `signature` field in manifest (reserved). `PACKAGE_VERIFY_SIGNATURES` env var (default false, log-only). No cryptographic code yet — schema slot reserved. |
|
||||
| OIDC state nonce validation | ✅ | `oidcClaims.Nonce` field added. `ValidateIDTokenNonce()` compares ID token nonce against stored state. Callback rejects mismatched nonces. |
|
||||
| Schema migration stub decision | ✅ | Stub replaced with log-only function documenting additive-only policy. Downgrade rejection preserved. |
|
||||
| ICD/SDK runner update pass | ✅ | ICD smoke tier: added metrics, cluster, backups, docs, OpenAPI JSON endpoints. SDK admin domain: added metrics, cluster, backups tests. |
|
||||
| Stale TODO resolution | ✅ | `main.go` session middleware: replaced with `OptionalAuth()` (auth if token present, anonymous pass-through). `auth.go:221` OIDC nonce: resolved. `starlark_helpers.go:96` migration stub: resolved. |
|
||||
|
||||
Then ship.
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.9.0 | Multi-Surface Packages |
|
||||
| v0.9.1 | Server-Side Sub-Path Routing |
|
||||
| v0.9.2 | Converter Consolidation |
|
||||
| v0.9.3 | Team User Roles |
|
||||
| v0.9.4 | Package Adoption + Roles |
|
||||
| v0.9.5 | Typed Forms SDK |
|
||||
| v0.9.6 | Stage Mode Collapse |
|
||||
| v0.9.7 | Workflow Starlark Write Ops |
|
||||
| v0.9.8 | Routing SDK Primitive |
|
||||
| v0.9.9 | Surface Access via Roles |
|
||||
|
||||
---
|
||||
|
||||
## Post-MVP
|
||||
## Current: v0.10.x — Panels + Composable Layout
|
||||
|
||||
- LLM participation (`llm-bridge` extension: subscribes to `chat.message.created`, calls `provider.complete()`, streams response via `realtime.publish`, posts via `chat.send()`. Bot participants with persona config. Multi-model conversations.)
|
||||
- Rich media extensions: image generation, code sandbox, STT/TTS
|
||||
- Desktop app (Tauri or Electron)
|
||||
- Sidecar tier: container-based extensions
|
||||
- Federation: cross-instance package sharing
|
||||
- Plugin marketplace with signing and review
|
||||
Panels are a new kernel rendering tier between surfaces (full-page) and
|
||||
block renderers (inline). They solve composable companion views — e.g.,
|
||||
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`.
|
||||
|
||||
**v0.10.0 — Panel Manifest + Lifecycle** *(completed)*
|
||||
|
||||
Manifest `panels` field (provider map + consumer array, soft dependency).
|
||||
`resolvePanels()` at surface load. `sw.panels` SDK module with full
|
||||
lifecycle API. Lazy JS loading with module caching. 10 new tests.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.10.1 | FloatingPanel Primitive |
|
||||
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||
| v0.10.3 | Panel Communication Patterns |
|
||||
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||
|
||||
---
|
||||
|
||||
### v0.11.x — Notes Reference Extension
|
||||
|
||||
Notes becomes the first reference extension — a production-quality
|
||||
knowledge base that exercises every kernel primitive. The UI/UX redesign
|
||||
is front-loaded as v0.11.0 so every subsequent feature version builds on
|
||||
a clean visual foundation. Design doc: `docs/DESIGN-notes-v011x.md`.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.11.0 | UI/UX Foundation — visual redesign |
|
||||
| v0.11.1 | Deep Folders + Navigation |
|
||||
| v0.11.2 | Wikilinks + Backlinks |
|
||||
| v0.11.3 | Live Preview + Rich Editing |
|
||||
| v0.11.4 | Note Sharing + Permissions |
|
||||
| v0.11.5 | Graph + Outline Hardening |
|
||||
| v0.11.6 | Quick Switcher + Commands |
|
||||
| v0.11.7 | Daily Notes + Templates |
|
||||
| v0.11.8 | Transclusion + Embeds |
|
||||
| v0.11.9 | Composability: Slots + Actions |
|
||||
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
### v0.12.x — Chat Reference Extension
|
||||
|
||||
Chat becomes the second reference extension — human-to-human messaging
|
||||
built entirely on Armature's extension architecture. Where notes proved
|
||||
surfaces, panels, and storage, chat proves **realtime**, **cross-package
|
||||
composition**, and **extensible data models**. Chat is human-to-human
|
||||
first; AI participants arrive via `llm-bridge` (v0.13.x) extending chat
|
||||
through folder attributes and slot contributions.
|
||||
Design doc: `docs/DESIGN-chat-v012x.md`.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.12.0 | UI/UX Foundation |
|
||||
| v0.12.1 | Conversation Folders + Attributes |
|
||||
| v0.12.2 | Reactions + Threads + Pins |
|
||||
| v0.12.3 | Rich Compose + Attachments |
|
||||
| v0.12.4 | @Mentions + Notifications |
|
||||
| v0.12.5 | Link Previews + Message Formatting |
|
||||
| v0.12.6 | Conversation Themes + Personality |
|
||||
| v0.12.7 | Composability: Slots + Actions |
|
||||
| v0.12.8 | Panels + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
### v0.13.x — Reference Libraries + Extensions
|
||||
|
||||
Custom public root surface unblocks everything — anonymous visitors,
|
||||
landing pages, and the package registry. `llm-bridge` is the key
|
||||
library delivery — extends both notes and chat through composability
|
||||
primitives, and introduces the **tool meta-tool** pattern:
|
||||
`sw.actions.list()` becomes the LLM tool registry, so installing an
|
||||
extension = granting AI a new capability. Zero configuration.
|
||||
`armature.run` deployment is the dogfood gate at the end of the series.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.13.0 | Custom Public Root Surface |
|
||||
| v0.13.1 | `vector-store` Library |
|
||||
| v0.13.2 | `llm-bridge` Core Library |
|
||||
| v0.13.3 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||
| v0.13.4 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||
| v0.13.5 | `file-share` Extension |
|
||||
| v0.13.6 | Package Registry Extension |
|
||||
| v0.13.7 | `code-workspace` Extension |
|
||||
| v0.13.8 | `image-gen` + `image-edit` Extensions |
|
||||
| v0.13.9 | Tool Meta-Tool Hardening + Scoping |
|
||||
| v0.13.10 | `armature.run` Deployment |
|
||||
| v0.13.11 | Integration Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
### v0.14.x — Sidecar Tier
|
||||
|
||||
Connect-inward model: sidecars connect TO the kernel (no K8s RBAC, no
|
||||
service mesh, no DNS discovery). Instance sidecars (shared infrastructure)
|
||||
and user sidecars (personal local tools). Design doc:
|
||||
`docs/DESIGN-sidecar-v014x.md`.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.14.0 | Sidecar Registry + Auth |
|
||||
| v0.14.1 | Capability Registration + Execution |
|
||||
| v0.14.2 | Kernel API Access + Event Bus |
|
||||
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||
|
||||
---
|
||||
|
||||
### v0.15.x — Polish + Stability
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.15.0 | Native Dialog Audit |
|
||||
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||
|
||||
---
|
||||
|
||||
### v1.0.0 — Stable Release
|
||||
|
||||
Kernel API surface frozen. Extensions are the product.
|
||||
|
||||
Gate criteria:
|
||||
|
||||
- All kernel Starlark modules documented with examples
|
||||
- All `api_routes` covered by OpenAPI spec
|
||||
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||
- Notes reference extension shipped with all v0.11.x features
|
||||
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
|
||||
- Chat reference extension shipped with all v0.12.x features
|
||||
- `llm-bridge` extends both notes and chat through composability
|
||||
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||
single conversation turn
|
||||
- Multi-persona context archetypes demonstrated
|
||||
- Admin safety rails validated
|
||||
- At least 2 panels consumed cross-package
|
||||
- At least 2 slot contributions per host surface demonstrated
|
||||
- Note and conversation sharing functional end-to-end
|
||||
- Headless E2E green on PG + SQLite
|
||||
- `armature-ca.sh` + mTLS deployment guide
|
||||
- Single-binary + Docker + K8s deployment paths documented
|
||||
- Instance sidecar (`armature-embed`) deployed and functional
|
||||
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||
- Token + mTLS sidecar auth both tested
|
||||
- `armature migrate` CLI functional
|
||||
|
||||
---
|
||||
|
||||
## Post-1.0 Horizon
|
||||
|
||||
These are candidates, not commitments. Each requires a design doc.
|
||||
|
||||
- **Federation** — cross-instance package sharing, identity federation
|
||||
- **Package marketplace** — signing, review, discovery registry
|
||||
- **Desktop app** — Tauri wrapper for local-first deployment
|
||||
- **Offline/sync** — SQLite-first with PG sync for field deployments
|
||||
- **Multi-tenant SaaS mode** — tenant isolation at the team boundary
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
| Principle | Implication |
|
||||
|-----------|-------------|
|
||||
| Extensions are the product | Chat, tasks, LLM, vector search, file sharing — all extensions. Zero kernel awareness of domain logic. |
|
||||
| Kernel stays thin | New kernel primitives require justification. If it can be an extension, it must be. |
|
||||
| Progressive enhancement | Every feature works on SQLite. PG adds performance. pgvector adds native vectors. S3 adds scalable storage. |
|
||||
| KISS-first | No unnecessary dependencies. Preact+htm (3KB), single binary, dual-DB from one codebase. |
|
||||
| Changeset discipline | Each CS independently CI-green. Design docs as implementation contracts. |
|
||||
| Pre-1.0 migration freedom | Schema changes fold into existing migrations. Post-1.0: proper versioned migrations. |
|
||||
|
||||
---
|
||||
|
||||
@@ -172,22 +270,42 @@ Then ship.
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Tasks → extension | Scheduler was the most entangled kernel component (~3,400 lines). Rebuilding as extension validates the trigger system and removes the worst compilation debt. Three trigger primitives (time, webhook, event) replace the monolithic scheduler. |
|
||||
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). |
|
||||
| `custom` stage mode | Stage mode `custom` delegates to a surface package, proving extension composability. Chat-in-workflow is handled by the chat extension, not the kernel. |
|
||||
| Providers removed from kernel | Provider configs, model catalog, routing policies — all moved to extension track. Kernel provides credential storage (connections) and the Starlark `provider.complete` module as the interface. |
|
||||
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. |
|
||||
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. |
|
||||
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. |
|
||||
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
|
||||
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. |
|
||||
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
||||
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
|
||||
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
|
||||
| Chat as extension, not kernel | Human-to-human chat built entirely as library + surface packages. Zero kernel awareness of conversations, messages, or participants. Kernel gains one generic `realtime` module. Proves near-infinite extensibility. LLM participation layers on top via a separate bridge extension — the chat system doesn't know or care whether a participant is human or AI. |
|
||||
| PG as consensus layer | Horizontal scaling uses PG as the sole coordinator (UNLOGGED node_registry + LISTEN/NOTIFY). No etcd, Consul, Redis, or Raft. Rationale: system is already tightly coupled to PG; adding a second consensus layer doubles operational complexity for zero benefit at homelab-to-small-team scale. UNLOGGED table is visible to all connections, survives session disconnect, and cleans up automatically on PG crash — correct behavior since all nodes are dead anyway. Sweep-all for health (every node deletes stale rows) is simpler than ring topology and has no edge cases. |
|
||||
| Two trigger tiers | Event + webhook triggers are extension-declared (manifest contract, full sandbox). Scheduled tasks are user-created ad-hoc (restricted sandbox — no raw HTTP, no DB table creation, connections-only outbound). Separation keeps extension contracts static and user automation safe. |
|
||||
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. |
|
||||
| Builtin package rationale | A builtin must enhance kernel surfaces or be required to demonstrate the platform's own capabilities. **notes** — primary content surface, exercises ext_data/storage/SDK/settings/realtime fully. **chat + chat-core** — primary communication surface, proves the extensibility thesis (100% extension, zero kernel awareness). **mermaid-renderer** — docs surface uses Mermaid diagrams to explain the architecture; without it the platform's own documentation doesn't render (self-bootstrapping). **schedules** — UI for the kernel's scheduled task system; without it users can't manage cron jobs (kernel primitive UI). All others are domain features, examples, dormant, or LLM-only tools — available via registry or `BUNDLED_PACKAGES=*`. |
|
||||
| Cluster dashboard retired | `cluster-dashboard` shipped as a standalone surface package (v0.6.0) as an expedient. Health/metrics belong inside the Admin surface as a tab — shared context, no separate nav entry, no install required. Merged in v0.6.4. |
|
||||
| Block renderers decoupled from chat | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` shipped with `"requires": ["chat"]` because renderer discovery lived inside the chat surface. These are content renderers, not chat features. v0.6.4 removes the constraint; v0.6.5 lifts renderer registration to the kernel SDK so all surfaces share it without reimplementing discovery. |
|
||||
| Tasks → extension | Three trigger primitives replace the monolithic scheduler. |
|
||||
| Sessions removed | Workflow instances with dedicated storage replace kernel sessions. |
|
||||
| `custom` stage mode | Delegates to a surface package, proving extension composability. |
|
||||
| Providers removed from kernel | Connections + Starlark `provider.complete` as the interface. |
|
||||
| Kernel permissions simplified | 6 platform permissions. Extensions define their own. |
|
||||
| Preact+htm retained | 3KB runtime, no build step, KISS. |
|
||||
| Single Docker image | Go binary + assets + migrations. |
|
||||
| Admin → RBAC group | Grant check replaces role check. |
|
||||
| Settings cascade | Scope auth + `user_overridable`. Two orthogonal axes. |
|
||||
| Chat as extension, not kernel | Zero kernel awareness. Proves extensibility thesis. |
|
||||
| PG as consensus layer | UNLOGGED node_registry + LISTEN/NOTIFY. No etcd/Consul/Redis. |
|
||||
| Two trigger tiers | Extension-declared (full sandbox) vs user ad-hoc (restricted). |
|
||||
| Two-slot topbar model | Left slot (title/branding) + center slot (`flex: 1`, tabs/pickers). |
|
||||
| `db` module is the structured store | No separate KV primitive. Extensions declare tables. |
|
||||
| `files` module rides ObjectStore | No new kernel tables. Metadata as companion objects. |
|
||||
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
||||
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
||||
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
||||
| Sidecar deferred to v0.14.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
|
||||
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
|
||||
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. Front-loading avoids double work. |
|
||||
| Notes as dedicated v0.11.x series (11 versions) | Complex enough to need changeset discipline. Each version independently shippable. |
|
||||
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||
| Chat human-to-human first (v0.12.x) | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||
| Chat as dedicated v0.12.x series (9 versions) | Second reference extension. Proves realtime, extensible data models, cross-package composition. |
|
||||
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||
| llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. |
|
||||
| Custom public root first in v0.13.x | Unblocks registry, landing page, and armature.run. Small kernel change, high leverage. |
|
||||
| Package registry as reference extension | Proves public surfaces work. Needs files module for .pkg storage. Self-referential: Armature's registry runs on Armature. |
|
||||
| armature.run as dogfood gate | If armature.run can't run on Armature, the platform isn't ready. Issues feed into quality gate fixes. |
|
||||
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery. Works on any deployment target. |
|
||||
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism. |
|
||||
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility. Defense in depth. |
|
||||
| Separate polish (v0.15.x) | Security-critical sidecar infra and quality-of-life polish shouldn't share focus. |
|
||||
|
||||
11
ci/Dockerfile.test-runner
Normal file
@@ -0,0 +1,11 @@
|
||||
# ci/Dockerfile.test-runner — Pre-built Playwright test runner
|
||||
#
|
||||
# Build & push (one-time, repeat when Playwright version bumps):
|
||||
# docker build -t registry.gobha.me:5000/ci-test-runner:latest -f ci/Dockerfile.test-runner .
|
||||
# docker push registry.gobha.me:5000/ci-test-runner:latest
|
||||
#
|
||||
# The CI compose override uses this image directly, avoiding npm install
|
||||
# on every run. Only the ci/ scripts are COPY'd at compose build time.
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
WORKDIR /work
|
||||
RUN npm init -y && npm install playwright@1.52.0
|
||||
225
ci/e2e-smoke-driver.js
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* E2E Smoke Driver — Playwright navigation smoke test
|
||||
*
|
||||
* Visits every installed surface and asserts:
|
||||
* 1. The shell topbar (.sw-topbar) renders
|
||||
* 2. No JS console errors
|
||||
* 3. The home link exists
|
||||
*
|
||||
* On failure: captures full-page screenshot + console log.
|
||||
*
|
||||
* Usage:
|
||||
* node ci/e2e-smoke-driver.js --server=http://localhost:3000 --token=TOKEN
|
||||
*
|
||||
* Exit codes: 0 = all passed, 1 = failures
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse args
|
||||
const args = {};
|
||||
process.argv.slice(2).forEach(a => {
|
||||
const [k, v] = a.replace(/^--/, '').split('=');
|
||||
args[k] = v;
|
||||
});
|
||||
|
||||
const SERVER = args.server || 'http://localhost:3000';
|
||||
const TOKEN = args.token || '';
|
||||
const SCREENSHOT_DIR = args.screenshots || '/tmp/e2e-screenshots';
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('ERROR: --token is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Surfaces that require URL parameters or aren't navigable directly
|
||||
const SKIP_SURFACES = new Set([
|
||||
'workflow', // requires /w/:id
|
||||
'workflow-landing', // requires /w/:id/public
|
||||
'welcome', // redirect/onboarding flow
|
||||
'test-runners', // tested separately by surface-test-driver
|
||||
]);
|
||||
|
||||
/**
|
||||
* Discover all surfaces: core + extension packages with surface_route.
|
||||
*/
|
||||
async function discoverSurfaces(page) {
|
||||
const surfaces = [];
|
||||
|
||||
// Core surfaces (always present)
|
||||
const coreSurfaces = [
|
||||
{ id: 'admin', route: '/admin' },
|
||||
{ id: 'settings', route: '/settings' },
|
||||
{ id: 'docs', route: '/docs' },
|
||||
{ id: 'team-admin', route: '/team-admin' },
|
||||
];
|
||||
|
||||
for (const s of coreSurfaces) {
|
||||
if (!SKIP_SURFACES.has(s.id)) {
|
||||
surfaces.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Extension surfaces from API
|
||||
try {
|
||||
const packages = await page.evaluate(async (serverUrl) => {
|
||||
const r = await fetch(serverUrl + '/api/v1/admin/packages', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (r.status === 200) {
|
||||
const body = await r.json();
|
||||
return body.data || body || [];
|
||||
}
|
||||
return [];
|
||||
}, SERVER);
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.enabled) continue;
|
||||
if (pkg.type !== 'surface' && pkg.type !== 'full') continue;
|
||||
if (SKIP_SURFACES.has(pkg.id)) continue;
|
||||
|
||||
// Extension surfaces live at /s/{id}
|
||||
const route = pkg.surface_route || ('/s/' + pkg.id);
|
||||
surfaces.push({ id: pkg.id, route });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WARN: Could not fetch package list:', e.message);
|
||||
}
|
||||
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
// ── Authenticate via page context ─────────
|
||||
// Navigate to health endpoint (no SPA boot, no SDK interference),
|
||||
// set the arm_token cookie via document.cookie, then navigate to
|
||||
// real surfaces. The Go page-auth middleware reads this cookie.
|
||||
//
|
||||
// We cannot use /login because the SDK boots, sees stale tokens in
|
||||
// localStorage, tries /auth/refresh, fails, and clears the cookie.
|
||||
// We cannot use Playwright's addCookies because SameSite=Strict
|
||||
// cookies set externally aren't sent in Docker DNS environments.
|
||||
console.log('Setting up auth...');
|
||||
await page.goto(SERVER + '/api/v1/health', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.evaluate((token) => {
|
||||
document.cookie = `arm_token=${token}; path=/; max-age=604800`;
|
||||
}, TOKEN);
|
||||
|
||||
// ── Discover surfaces ─────────────────────
|
||||
console.log('Discovering surfaces...');
|
||||
|
||||
await page.goto(SERVER + '/admin', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
|
||||
const surfaces = await discoverSurfaces(page);
|
||||
console.log(`Found ${surfaces.length} surfaces: ${surfaces.map(s => s.id).join(', ')}`);
|
||||
|
||||
if (surfaces.length === 0) {
|
||||
console.error('ERROR: No surfaces discovered');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Visit each surface ────────────────────
|
||||
const results = [];
|
||||
let failures = 0;
|
||||
|
||||
for (const surface of surfaces) {
|
||||
const url = SERVER + surface.route;
|
||||
const consoleErrors = [];
|
||||
|
||||
// Fresh console error collector per surface
|
||||
const onConsole = msg => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
// Ignore benign errors (favicon, service worker)
|
||||
if (text.includes('favicon') || text.includes('service-worker')) return;
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
};
|
||||
page.on('console', onConsole);
|
||||
|
||||
try {
|
||||
process.stdout.write(` ${surface.id} (${surface.route}) ... `);
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
|
||||
// Assert 1: Shell topbar renders (Preact SPA — wait for it to mount)
|
||||
const topbar = await page.waitForSelector('.sw-topbar', { timeout: 15000 })
|
||||
.catch(() => null);
|
||||
if (!topbar) {
|
||||
throw new Error('Shell topbar (.sw-topbar) not found after 15s');
|
||||
}
|
||||
|
||||
// Assert 2: Home link exists (favicon link or any link to /)
|
||||
const homeLink = await page.$('a[href="/"], a[href="./"], .sw-topbar__home');
|
||||
if (!homeLink) {
|
||||
throw new Error('Home link not found');
|
||||
}
|
||||
|
||||
// Assert 3: No JS console errors
|
||||
// Give a moment for any async errors to settle
|
||||
await page.waitForTimeout(500);
|
||||
if (consoleErrors.length > 0) {
|
||||
throw new Error(`JS console errors: ${consoleErrors.join('; ')}`);
|
||||
}
|
||||
|
||||
console.log('PASS');
|
||||
results.push({ surface: surface.id, status: 'pass' });
|
||||
|
||||
// Capture baseline screenshot (informational, not a gate)
|
||||
const slug = surface.id.replace(/[^a-z0-9-]/g, '_');
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, `baseline-${slug}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.log('FAIL — ' + err.message);
|
||||
failures++;
|
||||
results.push({ surface: surface.id, status: 'fail', error: err.message });
|
||||
|
||||
// Screenshot + console log on failure
|
||||
const slug = surface.id.replace(/[^a-z0-9-]/g, '_');
|
||||
try {
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, `fail-${slug}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
if (consoleErrors.length > 0) {
|
||||
fs.writeFileSync(
|
||||
path.join(SCREENSHOT_DIR, `fail-${slug}-console.log`),
|
||||
consoleErrors.join('\n')
|
||||
);
|
||||
}
|
||||
} catch (screenshotErr) {
|
||||
console.warn(' (could not capture screenshot:', screenshotErr.message + ')');
|
||||
}
|
||||
} finally {
|
||||
page.removeListener('console', onConsole);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────
|
||||
console.log('\n═══ E2E Smoke Summary ═══');
|
||||
console.log(` Total: ${results.length}`);
|
||||
console.log(` Passed: ${results.filter(r => r.status === 'pass').length}`);
|
||||
console.log(` Failed: ${failures}`);
|
||||
|
||||
if (failures > 0) {
|
||||
console.log('\nFailed surfaces:');
|
||||
for (const r of results.filter(r => r.status === 'fail')) {
|
||||
console.log(` ✗ ${r.surface}: ${r.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
})();
|
||||
74
ci/e2e-smoke-test.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Smoke Test — CI Entrypoint
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Authenticates as admin, then runs a Playwright navigation smoke
|
||||
# test against every installed surface. Asserts shell topbar
|
||||
# renders, no JS console errors, and the home link works.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Server running at $SERVER_URL (default: http://localhost:3000)
|
||||
# - npx playwright install chromium
|
||||
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
|
||||
#
|
||||
# Exit codes: 0 = all surfaces passed, 1 = failures detected
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
ADMIN_PASS="${ADMIN_PASS:-admin}"
|
||||
SCREENSHOT_DIR="${SCREENSHOT_DIR:-/tmp/e2e-screenshots}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}═══ E2E Smoke Test ═══${NC}"
|
||||
echo " Server: ${SERVER_URL}"
|
||||
|
||||
# ── Ensure screenshot dir ────────────────────
|
||||
mkdir -p "${SCREENSHOT_DIR}"
|
||||
|
||||
# ── Authenticate ─────────────────────────────
|
||||
# Try PAT first (from bootstrap), fall back to login
|
||||
PAT_FILE="/tmp/armature-admin-pat.txt"
|
||||
TOKEN=""
|
||||
|
||||
if [ -f "$PAT_FILE" ]; then
|
||||
TOKEN=$(cat "$PAT_FILE")
|
||||
echo -e "${GREEN}Authenticated via bootstrap PAT${NC}"
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${YELLOW}Authenticating via login...${NC}"
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Failed to authenticate${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Authenticated${NC}"
|
||||
|
||||
# ── Run via Playwright ───────────────────────
|
||||
echo -e "${YELLOW}Running E2E smoke tests via Playwright...${NC}"
|
||||
node "$(dirname "$0")/e2e-smoke-driver.js" \
|
||||
--server="${SERVER_URL}" \
|
||||
--token="${TOKEN}" \
|
||||
--screenshots="${SCREENSHOT_DIR}"
|
||||
|
||||
EXIT_CODE=$?
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}═══ All E2E smoke tests passed ═══${NC}"
|
||||
else
|
||||
echo -e "${RED}═══ E2E smoke tests FAILED ═══${NC}"
|
||||
echo -e "${YELLOW}Screenshots saved to: ${SCREENSHOT_DIR}${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
232
ci/e2e-workflow-handoff.sh
Executable file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Workflow Handoff Test
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Verifies the public→team handoff flow:
|
||||
# 1. Public visitor completes a form stage
|
||||
# 2. Visitor sees "submitted" screen (not the team stage)
|
||||
# 3. Team user sees assignment, claims it, completes review
|
||||
# 4. Instance status is "completed"
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Server running at $SERVER_URL (default: http://localhost:3000)
|
||||
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
|
||||
#
|
||||
# Exit codes: 0 = all assertions passed, 1 = failures detected
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
ADMIN_PASS="${ADMIN_PASS:-admin}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert() {
|
||||
local desc="$1" ok="$2"
|
||||
if [ "$ok" = "true" ]; then
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $desc"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse JSON field via node (portable)
|
||||
json_field() {
|
||||
node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d)$1||'')}catch(e){console.log('')}})" 2>/dev/null
|
||||
}
|
||||
|
||||
echo -e "${YELLOW}═══ E2E Workflow Handoff Test ═══${NC}"
|
||||
echo " Server: ${SERVER_URL}"
|
||||
|
||||
# ── Authenticate ─────────────────────────────
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| json_field ".token" || true)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Failed to authenticate${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Authenticated${NC}"
|
||||
|
||||
AUTH="Authorization: Bearer ${TOKEN}"
|
||||
|
||||
# Get admin user ID
|
||||
USER_ID=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/profile" | json_field ".id")
|
||||
assert "Got admin user ID" "$([ -n "$USER_ID" ] && echo true || echo false)"
|
||||
|
||||
# ── Phase 1: Create team ─────────────────────
|
||||
echo -e "\n${YELLOW}Phase 1: Create test team${NC}"
|
||||
|
||||
TEAM_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/admin/teams" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name": "E2E Handoff Team", "slug": "e2e-handoff-team"}' 2>/dev/null || echo '{}')
|
||||
|
||||
TEAM_ID=$(echo "$TEAM_RESP" | json_field ".id")
|
||||
assert "Team created" "$([ -n "$TEAM_ID" ] && echo true || echo false)"
|
||||
|
||||
# Add admin as team member
|
||||
if [ -n "$TEAM_ID" ] && [ -n "$USER_ID" ]; then
|
||||
curl -sf -X POST "${SERVER_URL}/api/v1/teams/${TEAM_ID}/members" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"user_id\":\"${USER_ID}\",\"role\":\"admin\"}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── Phase 2: Create workflow with public + team stages ──
|
||||
echo -e "\n${YELLOW}Phase 2: Create workflow (public form → team review)${NC}"
|
||||
|
||||
WF_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "E2E Handoff Test",
|
||||
"slug": "e2e-handoff-test",
|
||||
"description": "Tests public to team handoff",
|
||||
"entry_mode": "public_link",
|
||||
"is_active": true
|
||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
WF_ID=$(echo "$WF_RESP" | json_field ".id")
|
||||
assert "Workflow created" "$([ -n "$WF_ID" ] && echo true || echo false)"
|
||||
|
||||
if [ -z "$WF_ID" ]; then
|
||||
echo -e "${RED}Cannot continue without workflow ID${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stage 1: public form
|
||||
STAGE1_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Customer Request",
|
||||
"stage_mode": "form",
|
||||
"audience": "public",
|
||||
"ordinal": 0,
|
||||
"form_template": {
|
||||
"fields": [
|
||||
{"key": "name", "type": "text", "label": "Your Name", "required": true},
|
||||
{"key": "request", "type": "textarea", "label": "Request Details"}
|
||||
]
|
||||
}
|
||||
}' 2>/dev/null || echo '{}')
|
||||
|
||||
STAGE1_ID=$(echo "$STAGE1_RESP" | json_field ".id")
|
||||
assert "Public form stage created" "$([ -n "$STAGE1_ID" ] && echo true || echo false)"
|
||||
|
||||
# Stage 2: team review (with assignment)
|
||||
STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"Team Review\",
|
||||
\"stage_mode\": \"form\",
|
||||
\"audience\": \"team\",
|
||||
\"ordinal\": 1,
|
||||
\"assignment_team_id\": \"${TEAM_ID}\"
|
||||
}" 2>/dev/null || echo '{}')
|
||||
|
||||
STAGE2_ID=$(echo "$STAGE2_RESP" | json_field ".id")
|
||||
assert "Team review stage created" "$([ -n "$STAGE2_ID" ] && echo true || echo false)"
|
||||
|
||||
# ── Phase 3: Public visitor starts and completes form ──
|
||||
echo -e "\n${YELLOW}Phase 3: Public visitor submits form${NC}"
|
||||
|
||||
START_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflow-entry/global/e2e-handoff-test" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
INST_ID=$(echo "$START_RESP" | json_field ".id")
|
||||
ENTRY_TOKEN=$(echo "$START_RESP" | json_field ".entry_token")
|
||||
|
||||
assert "Instance created" "$([ -n "$INST_ID" ] && echo true || echo false)"
|
||||
assert "Entry token present" "$([ -n "$ENTRY_TOKEN" ] && echo true || echo false)"
|
||||
|
||||
# Submit form data (advance past stage 1)
|
||||
if [ -n "$ENTRY_TOKEN" ]; then
|
||||
ADV_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/public/workflows/advance/${ENTRY_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"data": {"name": "Test User", "request": "Please review this"}}' 2>/dev/null || echo '{}')
|
||||
|
||||
ADV_STATUS=$(echo "$ADV_RESP" | json_field ".status")
|
||||
assert "Instance advanced to team stage (status=active)" "$([ "$ADV_STATUS" = "active" ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ── Phase 4: Verify audience mismatch screen ──
|
||||
echo -e "\n${YELLOW}Phase 4: Verify visitor sees submitted screen${NC}"
|
||||
|
||||
if [ -n "$INST_ID" ]; then
|
||||
# Fetch the workflow page without auth (public visitor)
|
||||
WF_PAGE=$(curl -sf "${SERVER_URL}/w/${INST_ID}" 2>/dev/null || echo "")
|
||||
HAS_SUBMITTED=$(echo "$WF_PAGE" | grep -c "Submitted Successfully" || true)
|
||||
assert "Page shows 'Submitted Successfully'" "$([ "$HAS_SUBMITTED" -gt 0 ] && echo true || echo false)"
|
||||
|
||||
HAS_FORM=$(echo "$WF_PAGE" | grep -c 'id="formArea"' || true)
|
||||
assert "Page does NOT show form area" "$([ "$HAS_FORM" -eq 0 ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ── Phase 5: Team user sees assignment ─────────
|
||||
echo -e "\n${YELLOW}Phase 5: Team assignment appears${NC}"
|
||||
|
||||
if [ -n "$TEAM_ID" ]; then
|
||||
ASSIGN_RESP=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/teams/${TEAM_ID}/assignments?status=unassigned" 2>/dev/null || echo '{}')
|
||||
ASSIGN_COUNT=$(echo "$ASSIGN_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const r=JSON.parse(d); console.log((r.data||r).length||0)}catch(e){console.log(0)}})" 2>/dev/null)
|
||||
assert "Unassigned assignment exists" "$([ "$ASSIGN_COUNT" -gt 0 ] && echo true || echo false)"
|
||||
|
||||
# Get assignment ID
|
||||
ASSIGN_ID=$(echo "$ASSIGN_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const r=JSON.parse(d); console.log((r.data||r)[0].id||'')}catch(e){console.log('')}})" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Phase 6: Claim and complete assignment ─────
|
||||
echo -e "\n${YELLOW}Phase 6: Claim and complete${NC}"
|
||||
|
||||
if [ -n "$ASSIGN_ID" ]; then
|
||||
# Claim
|
||||
CLAIM_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/assignments/${ASSIGN_ID}/claim" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" -d '{}' 2>/dev/null || echo '{}')
|
||||
CLAIMED=$(echo "$CLAIM_RESP" | json_field ".claimed")
|
||||
assert "Assignment claimed" "$([ "$CLAIMED" = "true" ] && echo true || echo false)"
|
||||
|
||||
# Complete (advance the review stage)
|
||||
COMPLETE_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/assignments/${ASSIGN_ID}/complete" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"review_data": {"decision": "approved", "comment": "Looks good"}}' 2>/dev/null || echo '{}')
|
||||
COMPLETED=$(echo "$COMPLETE_RESP" | json_field ".completed")
|
||||
assert "Assignment completed" "$([ "$COMPLETED" = "true" ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ── Phase 7: Verify instance is completed ──────
|
||||
echo -e "\n${YELLOW}Phase 7: Verify final state${NC}"
|
||||
|
||||
if [ -n "$INST_ID" ]; then
|
||||
FINAL_RESP=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/workflows/${WF_ID}/instances/${INST_ID}" 2>/dev/null || echo '{}')
|
||||
FINAL_STATUS=$(echo "$FINAL_RESP" | json_field ".status")
|
||||
assert "Instance status is completed" "$([ "$FINAL_STATUS" = "completed" ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ── Cleanup ──────────────────────────────────
|
||||
echo -e "\n${YELLOW}Cleanup${NC}"
|
||||
|
||||
curl -sf -X DELETE "${SERVER_URL}/api/v1/workflows/${WF_ID}" -H "$AUTH" >/dev/null 2>&1 || true
|
||||
if [ -n "$TEAM_ID" ]; then
|
||||
curl -sf -X DELETE "${SERVER_URL}/api/v1/admin/teams/${TEAM_ID}" -H "$AUTH" >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo -e " Deleted test resources"
|
||||
|
||||
# ── Summary ──────────────────────────────────
|
||||
echo ""
|
||||
TOTAL=$((PASS + FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo -e "${GREEN}═══ All ${TOTAL} assertions passed ═══${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}═══ ${FAIL}/${TOTAL} assertions failed ═══${NC}"
|
||||
exit 1
|
||||
fi
|
||||
177
ci/e2e-workflow-nochat.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Workflow Test — Without Chat
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Verifies the complete workflow lifecycle works without chat or
|
||||
# chat-core packages installed. Uses only admin API + PAT auth.
|
||||
#
|
||||
# Proves: workflow independence from optional packages.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Server running at $SERVER_URL (default: http://localhost:3000)
|
||||
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
|
||||
#
|
||||
# Exit codes: 0 = all assertions passed, 1 = failures detected
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
ADMIN_PASS="${ADMIN_PASS:-admin}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert() {
|
||||
local desc="$1" ok="$2"
|
||||
if [ "$ok" = "true" ]; then
|
||||
echo -e " ${GREEN}✓${NC} $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $desc"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${YELLOW}═══ E2E Workflow Independence Test ═══${NC}"
|
||||
echo " Server: ${SERVER_URL}"
|
||||
|
||||
# ── Authenticate ─────────────────────────────
|
||||
TOKEN=""
|
||||
PAT_FILE="/tmp/armature-admin-pat.txt"
|
||||
|
||||
if [ -f "$PAT_FILE" ]; then
|
||||
TOKEN=$(cat "$PAT_FILE")
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Failed to authenticate${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Authenticated${NC}"
|
||||
|
||||
AUTH="Authorization: Bearer ${TOKEN}"
|
||||
|
||||
# ── Verify chat is NOT installed ─────────────
|
||||
echo -e "\n${YELLOW}Phase 1: Verify chat packages not required${NC}"
|
||||
|
||||
CHAT_PKG=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/admin/packages" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
|
||||
const pkgs = JSON.parse(d).data || JSON.parse(d);
|
||||
const chat = (Array.isArray(pkgs) ? pkgs : []).filter(p => p.id === 'chat' || p.id === 'chat-core');
|
||||
console.log(JSON.stringify(chat));
|
||||
})" 2>/dev/null || echo "[]")
|
||||
|
||||
echo " Chat packages found: ${CHAT_PKG}"
|
||||
|
||||
# ── Create a test workflow ───────────────────
|
||||
echo -e "\n${YELLOW}Phase 2: Create workflow via admin API${NC}"
|
||||
|
||||
WF_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "E2E No-Chat Test",
|
||||
"slug": "e2e-nochat-test",
|
||||
"description": "Workflow independence E2E test",
|
||||
"entry_mode": "public_link",
|
||||
"is_active": true
|
||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
WF_ID=$(echo "$WF_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
|
||||
|
||||
assert "Workflow created" "$([ -n "$WF_ID" ] && echo true || echo false)"
|
||||
|
||||
if [ -z "$WF_ID" ]; then
|
||||
echo -e "${RED}Cannot continue without workflow ID${NC}"
|
||||
echo "Response: ${WF_RESP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Add a form stage ─────────────────────────
|
||||
echo -e "\n${YELLOW}Phase 3: Add form stage${NC}"
|
||||
|
||||
STAGE_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Intake Form",
|
||||
"stage_mode": "form",
|
||||
"ordinal": 0,
|
||||
"form_template": {
|
||||
"fields": [
|
||||
{"key": "title", "type": "text", "label": "Issue Title", "required": true},
|
||||
{"key": "description", "type": "textarea", "label": "Description"}
|
||||
]
|
||||
}
|
||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
STAGE_ID=$(echo "$STAGE_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
|
||||
|
||||
assert "Form stage created" "$([ -n "$STAGE_ID" ] && echo true || echo false)"
|
||||
|
||||
# ── Add a review stage ───────────────────────
|
||||
REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Manager Review",
|
||||
"stage_mode": "form",
|
||||
"ordinal": 1
|
||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
|
||||
|
||||
assert "Review stage created" "$([ -n "$REVIEW_ID" ] && echo true || echo false)"
|
||||
|
||||
# ── Landing page responds ────────────────────
|
||||
echo -e "\n${YELLOW}Phase 4: Landing page accessible${NC}"
|
||||
|
||||
LANDING_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/global/e2e-nochat-test" 2>/dev/null || echo "000")
|
||||
assert "Landing page returns 200" "$([ "$LANDING_STATUS" = "200" ] && echo true || echo false)"
|
||||
|
||||
# ── Start workflow via public API ────────────
|
||||
echo -e "\n${YELLOW}Phase 5: Start workflow (public entry)${NC}"
|
||||
|
||||
START_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflow-entry/global/e2e-nochat-test" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
INST_ID=$(echo "$START_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c; process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
|
||||
|
||||
assert "Instance created" "$([ -n "$INST_ID" ] && echo true || echo false)"
|
||||
|
||||
# ── Workflow page renders ────────────────────
|
||||
echo -e "\n${YELLOW}Phase 6: Workflow execution page${NC}"
|
||||
|
||||
if [ -n "$INST_ID" ]; then
|
||||
WF_PAGE_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/${INST_ID}" 2>/dev/null || echo "000")
|
||||
assert "Workflow page returns 200" "$([ "$WF_PAGE_STATUS" = "200" ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ── Cleanup: delete workflow ─────────────────
|
||||
echo -e "\n${YELLOW}Cleanup${NC}"
|
||||
|
||||
curl -sf -X DELETE "${SERVER_URL}/api/v1/workflows/${WF_ID}" \
|
||||
-H "$AUTH" >/dev/null 2>&1 || true
|
||||
echo -e " Deleted test workflow"
|
||||
|
||||
# ── Summary ──────────────────────────────────
|
||||
echo ""
|
||||
TOTAL=$((PASS + FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo -e "${GREEN}═══ All ${TOTAL} assertions passed ═══${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}═══ ${FAIL}/${TOTAL} assertions failed ═══${NC}"
|
||||
exit 1
|
||||
fi
|
||||
56
ci/run-surface-tests.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface Test Runner — CI Entrypoint
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Authenticates as admin, navigates to the test-runners surface
|
||||
# via Playwright, runs all test suites, and asserts zero failures.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Server running at $SERVER_URL (default: http://localhost:3000)
|
||||
# - npx playwright install chromium
|
||||
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
|
||||
#
|
||||
# Exit codes: 0 = all tests passed, 1 = failures detected
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
ADMIN_PASS="${ADMIN_PASS:-admin}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}═══ Surface Test Runner ═══${NC}"
|
||||
echo " Server: ${SERVER_URL}"
|
||||
|
||||
# ── Authenticate ─────────────────────────────
|
||||
echo -e "${YELLOW}Authenticating...${NC}"
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Failed to authenticate${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Authenticated${NC}"
|
||||
|
||||
# ── Run via Playwright ───────────────────────
|
||||
echo -e "${YELLOW}Running surface tests via Playwright...${NC}"
|
||||
node "$(dirname "$0")/surface-test-driver.js" \
|
||||
--server="${SERVER_URL}" \
|
||||
--token="${TOKEN}"
|
||||
|
||||
EXIT_CODE=$?
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}═══ All surface tests passed ═══${NC}"
|
||||
else
|
||||
echo -e "${RED}═══ Surface tests FAILED ═══${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
141
ci/surface-test-driver.js
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Surface Test Driver — Playwright-based CI runner
|
||||
*
|
||||
* Navigates to /s/test-runners as admin, clicks "Run All",
|
||||
* waits for completion, then fetches results from the API.
|
||||
*
|
||||
* Usage:
|
||||
* node ci/surface-test-driver.js --server=http://localhost:3000 --token=TOKEN
|
||||
*
|
||||
* Exit codes: 0 = all passed, 1 = failures
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
// Parse args
|
||||
const args = {};
|
||||
process.argv.slice(2).forEach(a => {
|
||||
const [k, v] = a.replace(/^--/, '').split('=');
|
||||
args[k] = v;
|
||||
});
|
||||
|
||||
const SERVER = args.server || 'http://localhost:3000';
|
||||
const TOKEN = args.token || '';
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('ERROR: --token is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
// Authenticate via page context — navigate to a non-SPA endpoint,
|
||||
// set the arm_token cookie via document.cookie. Cannot use /login
|
||||
// (SDK boot clears stale tokens) or addCookies (SameSite=Strict
|
||||
// fails in Docker DNS environments).
|
||||
console.log('Setting up auth...');
|
||||
await page.goto(SERVER + '/api/v1/health', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.evaluate((token) => {
|
||||
document.cookie = `arm_token=${token}; path=/; max-age=604800`;
|
||||
}, TOKEN);
|
||||
|
||||
// Collect console errors
|
||||
const consoleErrors = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
try {
|
||||
// Navigate to test runners surface
|
||||
console.log('Navigating to test-runners surface...');
|
||||
await page.goto(SERVER + '/s/test-runners', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
|
||||
// Wait for the "Run All" button — suites load asynchronously so the
|
||||
// button only appears once runners have registered their suites.
|
||||
console.log('Waiting for Run All button...');
|
||||
const runAllBtn = await page.waitForSelector('button:has-text("Run All")', { timeout: 60000 })
|
||||
.catch(() => null);
|
||||
if (!runAllBtn) {
|
||||
// Dump page content for debugging
|
||||
const text = await page.textContent('body').catch(() => '(empty)');
|
||||
console.error('ERROR: "Run All" button not found. Page text:', text.substring(0, 500));
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Clicking Run All...');
|
||||
await runAllBtn.click();
|
||||
|
||||
// Wait for results — poll until running state clears
|
||||
console.log('Waiting for test completion...');
|
||||
// The running indicator disappears when tests finish
|
||||
// Max wait: 5 minutes
|
||||
const maxWait = 300000;
|
||||
const start = Date.now();
|
||||
let done = false;
|
||||
|
||||
while (!done && (Date.now() - start) < maxWait) {
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if results are available via API
|
||||
try {
|
||||
const resp = await page.evaluate(async (serverUrl) => {
|
||||
const r = await fetch(serverUrl + '/api/v1/admin/test-runners/results', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (r.status === 200) return await r.json();
|
||||
return null;
|
||||
}, SERVER);
|
||||
|
||||
if (resp && resp.summary) {
|
||||
done = true;
|
||||
console.log('\n═══ Results ═══');
|
||||
console.log(` Total: ${resp.summary.total}`);
|
||||
console.log(` Passed: ${resp.summary.passed}`);
|
||||
console.log(` Failed: ${resp.summary.failed}`);
|
||||
console.log(` Warned: ${resp.summary.warned}`);
|
||||
console.log(` Skipped: ${resp.summary.skipped}`);
|
||||
console.log(` Duration: ${resp.duration_ms}ms`);
|
||||
|
||||
if (resp.summary.failed > 0) {
|
||||
console.log('\n═══ Failures ═══');
|
||||
for (const suite of (resp.suites || [])) {
|
||||
for (const test of (suite.tests || [])) {
|
||||
if (test.status === 'failed') {
|
||||
console.log(` ✗ ${suite.name} > ${test.name}: ${test.detail || 'failed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (e) {
|
||||
// Results not ready yet
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
console.error('ERROR: Tests did not complete within timeout');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Driver error:', e.message);
|
||||
if (consoleErrors.length > 0) {
|
||||
console.error('\nBrowser console errors:');
|
||||
consoleErrors.forEach(e => console.error(' ' + e));
|
||||
}
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
21
ci/wait-for-healthy.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# Wait for Armature server to be healthy
|
||||
# ═══════════════════════════════════════════════
|
||||
# Usage: ./ci/wait-for-healthy.sh [URL] [MAX_RETRIES]
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-http://localhost:3000}"
|
||||
MAX_RETRIES="${2:-60}"
|
||||
|
||||
echo "Waiting for ${HOST} to be healthy..."
|
||||
for i in $(seq 1 "$MAX_RETRIES"); do
|
||||
if curl -sf --connect-timeout 2 "${HOST}/api/v1/health" -o /dev/null 2>/dev/null; then
|
||||
echo "Server healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Server not healthy after ${MAX_RETRIES}s"
|
||||
exit 1
|
||||
69
docker-compose.ci.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
# docker-compose.ci.yml — CI test override
|
||||
#
|
||||
# Extends base docker-compose.yml. Adds a healthcheck to armature and
|
||||
# Playwright-based test services. All containers share the default compose
|
||||
# bridge network, so services reach armature via Docker DNS (http://armature:80).
|
||||
#
|
||||
# Usage (test-runner):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
|
||||
# --abort-on-container-exit --exit-code-from test-runner
|
||||
#
|
||||
# Usage (e2e-smoke):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
|
||||
# --abort-on-container-exit --exit-code-from e2e-smoke
|
||||
#
|
||||
# The workflow exits with the selected service's exit code (0 = pass, 1 = fail).
|
||||
#
|
||||
# NOTE: Do NOT use network_mode: host — the workflow container and compose
|
||||
# containers are in separate network namespaces inside DinD. Use the default
|
||||
# bridge network and let services talk via Docker DNS instead.
|
||||
|
||||
services:
|
||||
armature:
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:80/api/v1/health"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
test-runner:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
WORKDIR /work
|
||||
RUN npm init -y && npm install playwright@1.52.0
|
||||
COPY ci/ /work/ci/
|
||||
RUN chmod +x /work/ci/*.sh
|
||||
depends_on:
|
||||
armature:
|
||||
condition: service_healthy
|
||||
working_dir: /work
|
||||
environment:
|
||||
SERVER_URL: http://armature:80
|
||||
ADMIN_USER: admin
|
||||
ADMIN_PASS: admin
|
||||
command: ["bash", "-c", "./ci/run-surface-tests.sh"]
|
||||
|
||||
e2e-smoke:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
WORKDIR /work
|
||||
RUN npm init -y && npm install playwright@1.52.0
|
||||
COPY ci/ /work/ci/
|
||||
RUN chmod +x /work/ci/*.sh
|
||||
depends_on:
|
||||
armature:
|
||||
condition: service_healthy
|
||||
working_dir: /work
|
||||
environment:
|
||||
SERVER_URL: http://armature:80
|
||||
ADMIN_USER: admin
|
||||
ADMIN_PASS: admin
|
||||
SCREENSHOT_DIR: /tmp/e2e-screenshots
|
||||
volumes:
|
||||
- /tmp/e2e-screenshots:/tmp/e2e-screenshots
|
||||
command: ["bash", "-c", "./ci/e2e-smoke-test.sh"]
|
||||
@@ -184,3 +184,30 @@ Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `pres
|
||||
| POST | `/presence/heartbeat` | Update presence status |
|
||||
| GET | `/presence` | Query online users |
|
||||
| GET | `/users/search` | Search users |
|
||||
|
||||
### Forms (v0.9.5)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/forms/validate` | Validate form data against a typed template |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"template": {
|
||||
"fields": [
|
||||
{"key": "name", "type": "text", "label": "Name", "required": true}
|
||||
]
|
||||
},
|
||||
"data": {"name": "Alice"}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{"valid": true, "errors": []}
|
||||
```
|
||||
|
||||
On validation failure, `errors` contains `[{"key": "name", "message": "Name is required"}]`.
|
||||
|
||||
@@ -271,12 +271,13 @@ graph TD
|
||||
## Frontend
|
||||
|
||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
||||
(except CM6 via esbuild). IIFE/global-namespace pattern with
|
||||
`sb.register()`/`sb.ns()`.
|
||||
(except CM6 via esbuild). ES modules loaded via `<script type="module">`.
|
||||
The SDK is exposed at `window.sw` — see the [Frontend JS Guide](FRONTEND-JS-GUIDE).
|
||||
|
||||
The shell loads surfaces into a viewport. Extensions use `window.html`
|
||||
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs
|
||||
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image.
|
||||
The shell provides a two-slot topbar (left title + center slot) that every
|
||||
surface inherits. Extensions use `window.html` and `window.preact` directly.
|
||||
Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
|
||||
CodeMirror 6) baked into the image.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
## Docker Single-Instance
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/armature/armature:latest
|
||||
docker pull gobha/armature:latest
|
||||
docker run -p 8080:80 \
|
||||
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
-v armature-data:/data \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
|
||||
armature:
|
||||
image: ghcr.io/armature/armature:latest
|
||||
image: gobha/armature:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
@@ -41,13 +41,13 @@ services:
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
volumes:
|
||||
- sb_storage:/data/storage
|
||||
- armature_storage:/data/storage
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
sb_storage:
|
||||
armature_storage:
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
@@ -70,6 +70,7 @@ See the `k8s/` directory for example manifests. Key considerations:
|
||||
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
|
||||
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
|
||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
||||
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
|
||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
|
||||
|
||||
466
docs/DESIGN-batch-exec.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# DESIGN — Concurrent Execution Primitive (`batch.exec`)
|
||||
|
||||
**Version:** v0.7.12
|
||||
**Status:** Implemented
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Starlark is single-threaded by design (`go.starlark.net` enforces one
|
||||
thread per execution). Extensions that need to fan out — calling multiple
|
||||
external APIs, invoking several library functions, or performing independent
|
||||
I/O operations — must do so sequentially. For two `http.post()` calls
|
||||
taking 200ms each, the extension blocks for 400ms regardless of whether
|
||||
the calls are independent.
|
||||
|
||||
The v0.7.10 `http.batch()` primitive solves the narrow case of parallel
|
||||
HTTP dispatch. But it doesn't help when the work is wrapped in library
|
||||
functions. If a `jira-client` library exposes `create_issue()` and a
|
||||
`confluence-client` library exposes `create_page()`, the extension author
|
||||
must either:
|
||||
|
||||
1. Call them sequentially (slow), or
|
||||
2. Decompose the library calls back into raw `http.post()` parameters
|
||||
to use `http.batch()` (defeats the purpose of having libraries).
|
||||
|
||||
The platform needs a general-purpose concurrent execution primitive that
|
||||
works with arbitrary Starlark callables — including library exports.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Shared mutable state between branches.** Each concurrent branch is
|
||||
fully isolated. No channels, no mutexes, no shared dicts. If branches
|
||||
need to coordinate, they don't belong in `batch.exec`.
|
||||
- **Unlimited concurrency.** A hard cap prevents extensions from spawning
|
||||
unbounded goroutines. This is a fan-out primitive, not a thread pool.
|
||||
- **Automatic retry or circuit breaking.** Error handling is the caller's
|
||||
responsibility. The kernel reports per-branch results and errors.
|
||||
- **Nested `batch.exec()`.** A callable inside `batch.exec` cannot itself
|
||||
call `batch.exec`. This prevents exponential goroutine growth and keeps
|
||||
the concurrency model flat.
|
||||
|
||||
---
|
||||
|
||||
## Key Insight: Frozen Libraries Are Thread-Safe
|
||||
|
||||
The reason this works without exotic machinery is `lib.require()`.
|
||||
|
||||
When a library is loaded via `lib.require()`, its exports are wrapped in
|
||||
a `starlarkstruct.FromStringDict()` — which produces a **frozen** struct.
|
||||
Frozen Starlark values are immutable and safe to read from any number of
|
||||
goroutines concurrently. This is a property of `go.starlark.net`, not
|
||||
something we enforce.
|
||||
|
||||
The only mutable state in a Starlark execution is:
|
||||
|
||||
1. **The `starlark.Thread` itself** — step counter, print buffer, cancel
|
||||
channel. Each branch gets its own thread.
|
||||
2. **Module instances** — `db`, `http`, `settings`, etc. contain
|
||||
configuration and hold references to shared Go objects (`*sql.DB`,
|
||||
`http.Client`). Each branch gets fresh module instances, but the
|
||||
underlying Go resources (`*sql.DB` connection pool, etc.) are already
|
||||
designed for concurrent access.
|
||||
3. **Local variables** — thread-local by definition in Starlark.
|
||||
|
||||
So the construction is: one new `starlark.Thread` + one new module set
|
||||
per branch, with frozen library structs shared read-only across all
|
||||
branches. This is exactly what `triggers/schedule.go` already does for
|
||||
scheduled task execution — `buildRestrictedModules` creates a fresh
|
||||
module set for each cron fire. `batch.exec` generalizes that pattern.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
```python
|
||||
results, errors = batch.exec([
|
||||
lambda: jira.create_issue(issue_data),
|
||||
lambda: confluence.create_page(page_data),
|
||||
lambda: slack.post_message(channel, msg),
|
||||
])
|
||||
|
||||
# results[0] = return value of jira.create_issue(), or None on error
|
||||
# errors[0] = None on success, or error string on failure
|
||||
# All three ran concurrently.
|
||||
```
|
||||
|
||||
### Signature
|
||||
|
||||
```
|
||||
batch.exec(callables, timeout=10) → (results: list, errors: list)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `callables` | `list[callable]` | Starlark callables (lambdas, named functions, bound methods). Max length: 8. |
|
||||
| `timeout` | `int` (optional) | Per-branch timeout in seconds. Default 10. Max 30. Inherits parent context deadline if shorter. |
|
||||
|
||||
**Returns:** A 2-tuple of equal-length lists.
|
||||
|
||||
- `results[i]` — the return value of `callables[i]`, or `None` if it
|
||||
errored.
|
||||
- `errors[i]` — `None` if `callables[i]` succeeded, or a string error
|
||||
message if it failed (timeout, step limit, runtime error).
|
||||
|
||||
**Errors (whole-call):**
|
||||
|
||||
- `callables` is empty → error
|
||||
- `callables` length > 8 → error
|
||||
- Any element is not callable → error
|
||||
- Permission `batch.exec` not granted → error
|
||||
|
||||
### Permission
|
||||
|
||||
New extension permission: `batch.exec`. Declared in manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["batch.exec"]
|
||||
}
|
||||
```
|
||||
|
||||
This is a separate permission because concurrent execution has resource
|
||||
implications (goroutines, module construction overhead). Extensions that
|
||||
don't need it shouldn't pay for it. The permission doesn't imply any
|
||||
other permissions — the branch inherits whatever modules the calling
|
||||
package already has.
|
||||
|
||||
---
|
||||
|
||||
## Execution Model
|
||||
|
||||
```
|
||||
batch.exec([fn_a, fn_b, fn_c])
|
||||
│
|
||||
├─── goroutine 1: Thread₁ + Modules₁ → fn_a() → result[0]
|
||||
├─── goroutine 2: Thread₂ + Modules₂ → fn_b() → result[1]
|
||||
└─── goroutine 3: Thread₃ + Modules₃ → fn_c() → result[2]
|
||||
│
|
||||
sync.WaitGroup.Wait()
|
||||
│
|
||||
return (results, errors)
|
||||
```
|
||||
|
||||
### Per-Branch Construction
|
||||
|
||||
For each callable in the input list, the kernel:
|
||||
|
||||
1. Creates a new `sandbox.Sandbox` with the same `Config` as the parent
|
||||
(same `MaxSteps` limit — each branch gets its own step budget, not a
|
||||
shared one).
|
||||
2. Calls `runner.buildModulesWithLibCtx()` with the **same** `packageID`,
|
||||
`manifest`, and `RunContext` as the parent invocation. This produces
|
||||
a fresh module set — new `DBModuleConfig`, new `HTTPModuleConfig`, etc.
|
||||
— pointing at the same underlying Go resources (`*sql.DB`, etc.).
|
||||
3. The `libContext` is **shared** (read path only — cached frozen exports).
|
||||
Library exports are immutable. The `loading` map (cycle detection) is
|
||||
not relevant because libraries are already loaded before `batch.exec`
|
||||
runs. If a branch triggers a new `lib.require()`, it would need its
|
||||
own `libContext` — see Open Questions.
|
||||
4. Creates a new `starlark.Thread` with the branch's print handler,
|
||||
step limit, and context-based cancellation.
|
||||
5. Calls `starlark.Call(thread, callable, nil, nil)` — the callable
|
||||
is a zero-arg lambda that closes over its arguments.
|
||||
|
||||
### Context & Cancellation
|
||||
|
||||
Each branch gets a child context derived from the parent with the
|
||||
per-branch timeout applied:
|
||||
|
||||
```go
|
||||
branchCtx, cancel := context.WithTimeout(parentCtx, branchTimeout)
|
||||
defer cancel()
|
||||
```
|
||||
|
||||
If the parent context is cancelled (e.g., HTTP request timeout), all
|
||||
branches are cancelled. If one branch exceeds its timeout, only that
|
||||
branch is cancelled — others continue.
|
||||
|
||||
### Goroutine Cap
|
||||
|
||||
Hard limit: **8 concurrent branches.** This is enforced at the API
|
||||
boundary (list length check), not via a semaphore. Rationale:
|
||||
|
||||
- 8 covers the real-world fan-out patterns (2-5 API calls, small batch
|
||||
operations). Nobody needs 50 concurrent Starlark branches.
|
||||
- Each branch allocates a `starlark.Thread` + module instances. At 8
|
||||
branches, overhead is bounded at ~8KB per thread + module construction
|
||||
time (~50μs per module set).
|
||||
- No semaphore means no queuing surprises. You get 8, period.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### New File: `sandbox/batch_module.go`
|
||||
|
||||
```go
|
||||
// BuildBatchModule creates the "batch" module.
|
||||
// Requires the Runner reference for per-branch module construction.
|
||||
func BuildBatchModule(
|
||||
ctx context.Context,
|
||||
runner *Runner,
|
||||
packageID string,
|
||||
manifest map[string]any,
|
||||
rc *RunContext,
|
||||
lc *libContext,
|
||||
) *starlarkstruct.Module
|
||||
```
|
||||
|
||||
The module holds a reference to the `Runner` — same pattern as
|
||||
`BuildLibModule`. It needs the runner to call `buildModulesWithLibCtx`
|
||||
for each branch.
|
||||
|
||||
### Core Implementation Sketch
|
||||
|
||||
```go
|
||||
func batchExec(ctx context.Context, runner *Runner, packageID string,
|
||||
manifest map[string]any, rc *RunContext, parentLC *libContext,
|
||||
) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var callableList *starlark.List
|
||||
var timeout int = 10
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"callables", &callableList,
|
||||
"timeout?", &timeout,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n := callableList.Len()
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("batch.exec: callables list is empty")
|
||||
}
|
||||
if n > 8 {
|
||||
return nil, fmt.Errorf("batch.exec: max 8 callables, got %d", n)
|
||||
}
|
||||
if timeout < 1 || timeout > 30 {
|
||||
timeout = 10
|
||||
}
|
||||
|
||||
// Validate all elements are callable.
|
||||
callables := make([]starlark.Callable, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c, ok := callableList.Index(i).(starlark.Callable)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("batch.exec: element %d is %s, not callable",
|
||||
i, callableList.Index(i).Type())
|
||||
}
|
||||
callables[i] = c
|
||||
}
|
||||
|
||||
// Execute concurrently.
|
||||
type branchResult struct {
|
||||
index int
|
||||
value starlark.Value
|
||||
err error
|
||||
}
|
||||
|
||||
results := make([]starlark.Value, n)
|
||||
errors := make([]starlark.Value, n)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, callable := range callables {
|
||||
wg.Add(1)
|
||||
go func(idx int, fn starlark.Callable) {
|
||||
defer wg.Done()
|
||||
|
||||
// Per-branch context with timeout.
|
||||
branchCtx, cancel := context.WithTimeout(ctx,
|
||||
time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Fresh module set for this branch.
|
||||
modules, err := runner.buildModulesWithLibCtx(
|
||||
branchCtx, packageID, manifest, rc, parentLC)
|
||||
if err != nil {
|
||||
results[idx] = starlark.None
|
||||
errors[idx] = starlark.String(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Fresh sandbox + thread.
|
||||
sb := New(DefaultConfig())
|
||||
val, _, callErr := sb.Call(branchCtx, fn, nil, nil)
|
||||
|
||||
if callErr != nil {
|
||||
results[idx] = starlark.None
|
||||
errors[idx] = starlark.String(callErr.Error())
|
||||
} else {
|
||||
results[idx] = val
|
||||
errors[idx] = starlark.None
|
||||
}
|
||||
}(i, callable)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return starlark.Tuple{
|
||||
starlark.NewList(results),
|
||||
starlark.NewList(errors),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Runner Wiring
|
||||
|
||||
In `buildModulesWithLibCtx`, add the permission case:
|
||||
|
||||
```go
|
||||
case models.ExtPermBatchExec:
|
||||
// Deferred — wired after module map is complete (needs runner ref).
|
||||
hasBatchExec = true
|
||||
```
|
||||
|
||||
After the module map is assembled:
|
||||
|
||||
```go
|
||||
if hasBatchExec {
|
||||
modules["batch"] = BuildBatchModule(ctx, r, packageID, manifest, rc, lc)
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Constant
|
||||
|
||||
In `models/permissions.go`:
|
||||
|
||||
```go
|
||||
ExtPermBatchExec = "batch.exec"
|
||||
```
|
||||
|
||||
Add to `AllExtensionPermissions` slice.
|
||||
|
||||
---
|
||||
|
||||
## Callable Closure Semantics
|
||||
|
||||
The callables passed to `batch.exec` are typically lambdas that close
|
||||
over variables from the calling scope:
|
||||
|
||||
```python
|
||||
issue_data = {"summary": "Review Q3 report"}
|
||||
page_data = {"title": "Q3 Report", "body": content}
|
||||
|
||||
results, errors = batch.exec([
|
||||
lambda: jira.create_issue(issue_data),
|
||||
lambda: confluence.create_page(page_data),
|
||||
])
|
||||
```
|
||||
|
||||
The closed-over values (`issue_data`, `page_data`, `jira`, `confluence`)
|
||||
are references to Starlark values in the calling thread's scope. Two
|
||||
safety properties make this work:
|
||||
|
||||
1. **Library exports (`jira`, `confluence`) are frozen.** They were
|
||||
returned by `lib.require()` as `starlarkstruct.FromStringDict()` —
|
||||
deeply immutable. Safe to read from any goroutine.
|
||||
|
||||
2. **Dict/list arguments may be mutable**, but Starlark's execution
|
||||
model means the calling thread is **blocked** waiting for
|
||||
`batch.exec` to return. No concurrent mutation is possible because
|
||||
the caller can't execute while the branches are running.
|
||||
|
||||
This is the same safety model as Go's `sync.WaitGroup` pattern: the
|
||||
goroutine that calls `wg.Wait()` cannot proceed until all goroutines
|
||||
complete, so values passed to goroutines before `wg.Add` are safe to
|
||||
read without locks.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### 1. `lib.require()` Inside Branches
|
||||
|
||||
If a callable triggers a `lib.require()` that hasn't been cached yet,
|
||||
the shared `libContext.cache` would be written from a goroutine. Options:
|
||||
|
||||
**A. Prohibit: branches cannot call `lib.require()`.** The branch gets
|
||||
a nil `libContext`, so `lib` module is unavailable inside `batch.exec`.
|
||||
Libraries must be loaded before the batch call. Simplest, safest.
|
||||
|
||||
**B. Per-branch `libContext` with shared read cache.** Each branch gets
|
||||
its own `libContext` whose `cache` is pre-populated from the parent's
|
||||
cache (snapshot). New loads go into the branch's local cache only.
|
||||
Slightly wasteful if two branches load the same library (loaded twice),
|
||||
but safe.
|
||||
|
||||
**C. Mutex-protected shared `libContext`.** Add a `sync.RWMutex` to
|
||||
`libContext`. Reads use `RLock`, writes use `Lock`. Minimal overhead,
|
||||
but makes `libContext` aware of concurrency — violates its current
|
||||
assumptions.
|
||||
|
||||
**Recommendation: Option A for v0.7.11, Option B as follow-up if needed.**
|
||||
In practice, extensions call `lib.require()` at module scope (top of
|
||||
script), not inside request handlers. The lambdas passed to `batch.exec`
|
||||
call methods on already-loaded library structs. Option A covers all
|
||||
real-world patterns.
|
||||
|
||||
### 2. Print Output
|
||||
|
||||
Each branch has its own print buffer (the `output strings.Builder` in
|
||||
`Sandbox.Call`). Options:
|
||||
|
||||
**A. Discard.** Branch print output is lost. Simple, avoids interleaving.
|
||||
|
||||
**B. Collect per-branch.** Return a third list: `(results, errors, outputs)`.
|
||||
Useful for debugging but clutters the API.
|
||||
|
||||
**C. Merge into parent.** Append all branch output to the parent thread's
|
||||
print buffer, prefixed with branch index. Requires passing the parent's
|
||||
`outputMu` and `output` builder — invasive.
|
||||
|
||||
**Recommendation: Option A for v0.7.11.** `print()` in Starlark is a
|
||||
debugging tool, not a production logging facility. Branch callables that
|
||||
need to report status should return structured data. If debugging demand
|
||||
emerges, Option B is a backward-compatible addition.
|
||||
|
||||
### 3. Step Limit Scope
|
||||
|
||||
Each branch gets its own `MaxSteps` budget (default 1M). Should the
|
||||
total across all branches be capped?
|
||||
|
||||
**No.** The per-branch cap is sufficient. 8 branches × 1M steps = 8M
|
||||
total, which completes in under a second on any modern hardware. The
|
||||
wall-clock timeout (per-branch, max 30s) is the real resource guard.
|
||||
Adding a cross-branch step budget creates coupling between independent
|
||||
execution paths — branch A's step count shouldn't affect branch B's
|
||||
ability to complete.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| Parallel ordering | 3 callables with different sleep durations. Results in input order, not completion order. |
|
||||
| Partial failure | 3 callables, middle one raises error. results = [val, None, val], errors = [None, "err msg", None]. |
|
||||
| Timeout per-branch | One callable sleeps beyond timeout. Others succeed. Timed-out branch returns error. |
|
||||
| Parent cancellation | Cancel parent context mid-execution. All branches cancelled. |
|
||||
| Cap enforcement | List of 9 callables → immediate error, nothing executed. |
|
||||
| Empty list | `batch.exec([])` → error. |
|
||||
| Non-callable element | `batch.exec([1, 2])` → error, nothing executed. |
|
||||
| Permission gating | Package without `batch.exec` permission → module not available. |
|
||||
| Frozen library sharing | Two branches call same frozen library function concurrently. No race. |
|
||||
| Nested batch.exec | Callable inside batch.exec attempts batch.exec → error (module not injected in branch). |
|
||||
| db module isolation | Two branches insert into same table concurrently. Both succeed, no corruption. |
|
||||
| http module isolation | Two branches make HTTP calls with different headers. No cross-contamination. |
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
No schema changes. No new tables. No new migrations.
|
||||
|
||||
New permission constant `batch.exec` added to `models/permissions.go`.
|
||||
Extensions must declare the permission in their manifest to access the
|
||||
`batch` module.
|
||||
1074
docs/DESIGN-chat-v012x.md
Normal file
687
docs/DESIGN-extension-composability.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# DESIGN — Extension Composability
|
||||
|
||||
**Version:** v0.8.5
|
||||
**Status:** Implemented
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Extensions cannot meaningfully compose with each other. A Notes surface
|
||||
can't accept toolbar buttons from an STT extension. An LLM bridge can't
|
||||
invoke an image generator's functions. A chat surface can't display action
|
||||
buttons contributed by an image-editing extension.
|
||||
|
||||
The runtime primitives for contribution exist — `sw.slots`, `sw.actions`,
|
||||
and `sw.renderers` are live in the SDK. But there is no manifest layer
|
||||
to declare the relationships, no backend mechanism for non-library packages
|
||||
to call each other's exported functions, and no conventions for slot
|
||||
naming or context contracts.
|
||||
|
||||
This blocks the entire "extensions extending extensions" pattern that
|
||||
makes the platform an ecosystem rather than a collection of packages.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Arbitrary inter-extension communication (message passing, shared memory).
|
||||
Extensions compose through declared slots, exported functions, and events.
|
||||
- Runtime dependency injection. Dependencies are declared in manifests and
|
||||
resolved at load time.
|
||||
- Extension sandboxing on the frontend. Browser-tier JS runs in the same
|
||||
page context. Isolation is by convention and code review, not enforcement.
|
||||
|
||||
---
|
||||
|
||||
## What Already Exists
|
||||
|
||||
Three SDK registries are live and functional:
|
||||
|
||||
**`sw.slots`** — Named UI injection points. `register(name, {id, component, priority})`
|
||||
adds a Preact component to a named slot. `get(name)` returns sorted entries.
|
||||
Emits `slots.changed` events. Dedup by id. Priority ordering.
|
||||
|
||||
**`sw.actions`** — Named callable actions. `register(id, {handler, label, icon})`
|
||||
exposes a function by name. `run(id, ...args)` invokes it. Cross-extension
|
||||
function calls on the frontend.
|
||||
|
||||
**`sw.renderers`** — Block and post renderers for markdown content.
|
||||
`register(name, {type, pattern, render})`. Already used by mermaid, KaTeX,
|
||||
CSV, and diff extensions.
|
||||
|
||||
**`lib.require()`** — Backend cross-package function calls. Starlark
|
||||
packages call exported functions from library packages with the library's
|
||||
own permission context.
|
||||
|
||||
The gap: `sw.slots` has no manifest awareness — the admin can't see which
|
||||
extensions contribute where, install/uninstall can't warn about orphaned
|
||||
contributions. `lib.require()` is restricted to `type: "library"` packages,
|
||||
so a full package like an image generator can't export callable functions.
|
||||
There are no conventions for slot naming or context contracts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three additions: manifest declarations, backend relaxation, and SDK helpers.
|
||||
|
||||
### 1. Manifest Declarations
|
||||
|
||||
#### Host Surfaces: `slots` Field
|
||||
|
||||
Surfaces declare named injection points with context descriptions:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string — current note ID",
|
||||
"getContent": "function — returns note body text",
|
||||
"setContent": "function — replaces note body text"
|
||||
}
|
||||
},
|
||||
"note-footer": {
|
||||
"description": "Content rendered below note body",
|
||||
"context": {
|
||||
"noteId": "string",
|
||||
"content": "string — rendered HTML content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat",
|
||||
"slots": {
|
||||
"message-actions": {
|
||||
"description": "Action buttons on individual messages",
|
||||
"context": {
|
||||
"messageId": "string",
|
||||
"content": "string — message text",
|
||||
"attachments": "array — [{url, type, name}]"
|
||||
}
|
||||
},
|
||||
"image-actions": {
|
||||
"description": "Action buttons overlaid on rendered images",
|
||||
"context": {
|
||||
"imageUrl": "string",
|
||||
"messageId": "string",
|
||||
"metadata": "object — generation params if available"
|
||||
}
|
||||
},
|
||||
"composer-tools": {
|
||||
"description": "Tool buttons in the message composer area",
|
||||
"context": {
|
||||
"conversationId": "string",
|
||||
"insertText": "function — appends to composer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `context` field is documentation, not enforcement. It tells extension
|
||||
authors what data the slot provides. The kernel parses and stores slot
|
||||
declarations but does not validate context shapes at runtime.
|
||||
|
||||
#### Contributing Extensions: `contributes` Field
|
||||
|
||||
Extensions declare which slots they inject into:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-dictate",
|
||||
"title": "Note Dictation",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text note dictation"
|
||||
}
|
||||
},
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "image-gen",
|
||||
"title": "Image Generator",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"contributes": {
|
||||
"chat:composer-tools": {
|
||||
"label": "Generate Image",
|
||||
"icon": "🎨",
|
||||
"description": "AI image generation from text prompt"
|
||||
}
|
||||
},
|
||||
"exports": ["generate", "list_models"],
|
||||
"permissions": ["api.http", "connections.read", "files.write", "db.write"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "image-edit",
|
||||
"title": "Image Editor",
|
||||
"type": "extension",
|
||||
"tier": "starlark",
|
||||
"contributes": {
|
||||
"chat:image-actions": {
|
||||
"label": "Edit Image",
|
||||
"icon": "✏️",
|
||||
"description": "Inpaint, outpaint, upscale, style transfer"
|
||||
}
|
||||
},
|
||||
"exports": ["inpaint", "outpaint", "upscale", "restyle"],
|
||||
"permissions": ["api.http", "connections.read", "files.write"]
|
||||
}
|
||||
```
|
||||
|
||||
**Slot naming convention:** `{host-package-id}:{slot-name}`. The colon
|
||||
separates namespace from slot. Extensions contributing to `notes:toolbar-actions`
|
||||
are declaring a relationship with the `notes` package.
|
||||
|
||||
#### What the Kernel Does with Declarations
|
||||
|
||||
**At install time:**
|
||||
- Parse `slots` field → store in manifest (no separate table needed).
|
||||
- Parse `contributes` field → validate that each target slot name follows
|
||||
the `{pkg}:{slot}` convention. Do NOT validate that the host package
|
||||
exists — contributing extensions can be installed before or after their
|
||||
host. Soft coupling.
|
||||
- Parse `exports` field → already stored in manifest. No changes needed.
|
||||
|
||||
**At admin display time:**
|
||||
- `GET /api/v1/admin/packages/:id` includes `slots` and `contributes`
|
||||
from manifest. Admin UI shows "This package provides X slots" and
|
||||
"This package contributes to Y slots."
|
||||
- New endpoint: `GET /api/v1/admin/slots` → returns a map of all declared
|
||||
slots across installed packages with their contributors. Built by
|
||||
scanning all package manifests — no new table.
|
||||
|
||||
**At uninstall time:**
|
||||
- If uninstalling a package that declares `slots`, check if any enabled
|
||||
packages declare `contributes` targeting those slots. Warn (not block):
|
||||
"Uninstalling 'notes' will orphan contributions from: note-dictate,
|
||||
note-ai." The admin decides.
|
||||
- If uninstalling a contributing package, no warning needed — the slot
|
||||
just has fewer entries.
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend: `lib.require()` Relaxation
|
||||
|
||||
Currently `lib.require()` enforces `libPkg.Type == "library"`. This
|
||||
prevents full packages (which have surfaces, settings, UI) from also
|
||||
exporting callable functions.
|
||||
|
||||
**Change:** Replace the type check with an exports check.
|
||||
|
||||
In `sandbox/lib_module.go`, the current guard:
|
||||
|
||||
```go
|
||||
if libPkg.Type != "library" {
|
||||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||||
}
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```go
|
||||
exports := extractExports(libPkg.Manifest)
|
||||
if len(exports) == 0 {
|
||||
return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID)
|
||||
}
|
||||
```
|
||||
|
||||
Any package that declares `"exports": [...]` is callable via `lib.require()`,
|
||||
regardless of type. The dependency check (`ListByConsumer`) remains
|
||||
enforced — the consumer must still declare `"depends": ["image-gen"]`
|
||||
in its manifest.
|
||||
|
||||
The `depends` field already accepts any package ID, not just libraries.
|
||||
The `DependencyStore` has no type check. This change is one line in
|
||||
`lib_module.go`.
|
||||
|
||||
**Security implication:** A full package's exported functions run with
|
||||
that package's own permissions, same as libraries today. An image-gen
|
||||
package with `api.http` + `files.write` permissions exposes `generate()`
|
||||
— when llm-bridge calls it, it runs with image-gen's permissions, not
|
||||
llm-bridge's. This is correct and already how `lib.require()` works.
|
||||
|
||||
---
|
||||
|
||||
### 3. SDK Additions
|
||||
|
||||
#### `sw.slots.renderAll(name, context)` Helper
|
||||
|
||||
Currently host surfaces must manually iterate slot entries:
|
||||
|
||||
```javascript
|
||||
const entries = sw.slots.get('notes:toolbar-actions');
|
||||
return entries.map(e => html`<${e.component} ...${ctx} />`);
|
||||
```
|
||||
|
||||
Add a convenience helper:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Render all components registered in a slot.
|
||||
* @param {string} name — slot name
|
||||
* @param {object} context — props passed to each component
|
||||
* @returns {Array<VNode>} — array of rendered vnodes
|
||||
*/
|
||||
renderAll(name, context = {}) {
|
||||
return this.get(name).map(e => {
|
||||
try {
|
||||
return e.component(context);
|
||||
} catch (err) {
|
||||
console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
```
|
||||
|
||||
Host surface usage becomes:
|
||||
|
||||
```javascript
|
||||
// In Notes toolbar:
|
||||
html`<div class="notes-toolbar">
|
||||
<button onclick=${save}>Save</button>
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: currentNote.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text)
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
#### `sw.slots.declare(name, description)` (Optional)
|
||||
|
||||
Runtime declaration for slots that don't appear in the manifest (e.g.,
|
||||
dynamically created slots). Mostly for discoverability — the debug panel
|
||||
can list all active slots whether manifest-declared or runtime-declared.
|
||||
|
||||
```javascript
|
||||
// In chat surface, when rendering an image:
|
||||
sw.slots.declare('chat:image-actions', 'Action buttons on images');
|
||||
```
|
||||
|
||||
Not enforced — `sw.slots.register()` works on any name regardless.
|
||||
This is a documentation/debugging aid only.
|
||||
|
||||
---
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Pattern A: LLM Tool Registration
|
||||
|
||||
LLM bridge exposes a tool registry. Tool extensions register at load time
|
||||
via `lib.require()` on the backend and `sw.actions` on the frontend.
|
||||
|
||||
**llm-bridge (library) — script.star:**
|
||||
```python
|
||||
_tools = {}
|
||||
|
||||
def register_tool(name, description, parameters, handler):
|
||||
"""Register a callable tool for LLM function calling."""
|
||||
_tools[name] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
"handler": handler,
|
||||
}
|
||||
|
||||
def complete(messages, tools=None):
|
||||
"""Send messages to LLM with registered tools available."""
|
||||
tool_schemas = []
|
||||
for t in _tools.values():
|
||||
tool_schemas.append({
|
||||
"name": t["name"],
|
||||
"description": t["description"],
|
||||
"parameters": t["parameters"],
|
||||
})
|
||||
# ... call LLM API with tool_schemas, handle tool_use responses
|
||||
# by dispatching to _tools[name]["handler"]
|
||||
```
|
||||
|
||||
**image-gen (full package) — script.star:**
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
|
||||
def generate(prompt, model="dall-e-3", size="1024x1024", seed=None):
|
||||
conn = connections.get("openai")
|
||||
# ... call API, store result in files module ...
|
||||
return {"image_url": url, "prompt": prompt, "seed": actual_seed}
|
||||
|
||||
# Register as LLM tool at load time
|
||||
llm.register_tool(
|
||||
name="generate_image",
|
||||
description="Generate an image from a text description",
|
||||
parameters={"prompt": "string", "size": "string"},
|
||||
handler=generate,
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern B: UI Contribution (Toolbar Buttons)
|
||||
|
||||
**note-dictate (extension) — js/index.js:**
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-dictate',
|
||||
priority: 200,
|
||||
component: ({ noteId, setContent, getContent }) => {
|
||||
const [recording, setRecording] = preact.useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
if (recording) {
|
||||
// Stop recording, send audio to STT api_route
|
||||
const text = await sw.api.ext('note-dictate').post('/transcribe', {
|
||||
audio: audioBlob
|
||||
});
|
||||
setContent(getContent() + '\n' + text.transcription);
|
||||
setRecording(false);
|
||||
} else {
|
||||
// Start recording
|
||||
setRecording(true);
|
||||
}
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--ghost"
|
||||
onclick=${toggle}
|
||||
title="Dictate">
|
||||
${recording ? '⏹' : '🎤'}
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**notes (surface) — toolbar rendering:**
|
||||
```javascript
|
||||
html`<div class="notes-toolbar__actions">
|
||||
<button onclick=${() => saveNote()}>💾</button>
|
||||
<button onclick=${() => togglePin()}>📌</button>
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: note.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text),
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
Notes doesn't know dictation exists. Dictation doesn't know Notes'
|
||||
internals — just the slot contract (noteId, getContent, setContent).
|
||||
|
||||
### Pattern C: Image Actions (Generate + Edit + Upscale)
|
||||
|
||||
Three independent extensions contribute to the same image display slot:
|
||||
|
||||
**chat surface — image rendering:**
|
||||
```javascript
|
||||
function ChatImage({ src, messageId, metadata }) {
|
||||
return html`<div class="chat-image">
|
||||
<img src=${src} />
|
||||
<div class="chat-image__actions">
|
||||
${sw.slots.renderAll('chat:image-actions', {
|
||||
imageUrl: src,
|
||||
messageId,
|
||||
metadata,
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
**image-gen — contributes regen button:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-gen:regenerate',
|
||||
priority: 100,
|
||||
component: ({ imageUrl, metadata }) => {
|
||||
const regen = async () => {
|
||||
// Open prompt editor with original params
|
||||
const params = await sw.prompt('Edit prompt', {
|
||||
default: metadata?.prompt || '',
|
||||
multiline: true,
|
||||
});
|
||||
if (!params) return;
|
||||
const result = await sw.api.ext('image-gen').post('/generate', {
|
||||
prompt: params,
|
||||
seed: metadata?.seed,
|
||||
negative_prompt: metadata?.negative_prompt,
|
||||
});
|
||||
// result triggers a new chat message with the generated image
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${regen} title="Regenerate">
|
||||
🔄
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**image-edit — contributes edit drawer:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-edit:edit',
|
||||
priority: 200,
|
||||
component: ({ imageUrl, metadata }) => {
|
||||
const openEditor = () => {
|
||||
// Open a drawer with editing options
|
||||
sw.emit('drawer.open', {
|
||||
title: 'Edit Image',
|
||||
component: ImageEditPanel,
|
||||
props: { imageUrl, metadata },
|
||||
});
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${openEditor} title="Edit">
|
||||
✏️
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**image-upscale — contributes upscale button:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-upscale:upscale',
|
||||
priority: 300,
|
||||
component: ({ imageUrl }) => {
|
||||
const upscale = async () => {
|
||||
const result = await sw.api.ext('image-upscale').post('/upscale', {
|
||||
image_url: imageUrl,
|
||||
scale: 2,
|
||||
});
|
||||
// Display or replace with upscaled image
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${upscale} title="Upscale 2×">
|
||||
🔍
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Result: an image in chat gets three action buttons from three separate
|
||||
extensions. Install one, get one button. Install all three, get all three.
|
||||
Uninstall one, the others keep working. The chat surface has no knowledge
|
||||
of any of them.
|
||||
|
||||
### Pattern D: LLM Note Restructuring
|
||||
|
||||
Combines backend composability (lib.require) with frontend contribution
|
||||
(slots):
|
||||
|
||||
**note-ai (extension) — manifest.json:**
|
||||
```json
|
||||
{
|
||||
"id": "note-ai",
|
||||
"type": "extension",
|
||||
"tier": "starlark",
|
||||
"depends": ["llm-bridge"],
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "AI Restructure",
|
||||
"icon": "✨"
|
||||
}
|
||||
},
|
||||
"permissions": ["api.http"],
|
||||
"settings": {
|
||||
"restructure_prompt": {
|
||||
"type": "string",
|
||||
"label": "Restructure Prompt",
|
||||
"description": "System prompt for AI note restructuring",
|
||||
"default": "Restructure the following note into clear sections with headers. Preserve all information. Use markdown formatting.",
|
||||
"user_overridable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**note-ai — script.star (api_route handler):**
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
|
||||
def on_request(req):
|
||||
if req["method"] == "POST" and req["path"] == "/restructure":
|
||||
content = req["body"]["content"]
|
||||
prompt = settings.get("restructure_prompt")
|
||||
result = llm.complete([
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": content},
|
||||
])
|
||||
return {"status": 200, "body": {"restructured": result["text"]}}
|
||||
```
|
||||
|
||||
**note-ai — js/index.js:**
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-ai:restructure',
|
||||
priority: 500,
|
||||
component: ({ noteId, getContent, setContent }) => {
|
||||
const [loading, setLoading] = preact.useState(false);
|
||||
|
||||
const restructure = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await sw.api.ext('note-ai').post('/restructure', {
|
||||
content: getContent()
|
||||
});
|
||||
setContent(result.restructured);
|
||||
sw.toast('Note restructured', 'success');
|
||||
} catch (e) {
|
||||
sw.toast('Restructure failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--ghost"
|
||||
onclick=${restructure}
|
||||
disabled=${loading}
|
||||
title="AI Restructure">
|
||||
${loading ? '⏳' : '✨'}
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The user configures their restructuring prompt in Settings. The button
|
||||
appears in Notes toolbar. Clicking it sends the note content to the
|
||||
api_route, which calls llm-bridge, which calls the configured LLM provider.
|
||||
Four packages involved (notes, note-ai, llm-bridge, the LLM connection),
|
||||
zero hardcoded dependencies between surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Kernel Changes
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sandbox/lib_module.go` | Replace type check with exports check (~1 line) |
|
||||
| `handlers/extensions.go` | Parse `contributes` and `slots` manifest fields (validation) |
|
||||
| `handlers/packages.go` | Uninstall warning for orphaned contributions |
|
||||
| `src/js/sw/sdk/slots.js` | Add `renderAll(name, context)` and `declare(name, desc)` |
|
||||
| `docs/PACKAGE-FORMAT.md` | Document `slots`, `contributes`, `exports` fields |
|
||||
| `docs/EXTENSION-GUIDE.md` | Composability patterns section |
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `handlers/admin_slots.go` | `GET /admin/slots` aggregation endpoint |
|
||||
|
||||
### No New:
|
||||
|
||||
- Database tables
|
||||
- Migrations
|
||||
- Starlark modules
|
||||
- Permission constants
|
||||
- Config env vars
|
||||
|
||||
This is deliberately small. The runtime infrastructure exists. The
|
||||
design adds manifest-level visibility, one backend guard relaxation,
|
||||
and one SDK helper. The composability comes from conventions and
|
||||
documentation, not kernel complexity.
|
||||
|
||||
---
|
||||
|
||||
## Slot Catalog (Initial)
|
||||
|
||||
These are the recommended slots for first-party packages. Extension
|
||||
authors may define additional slots following the naming convention.
|
||||
|
||||
| Slot | Host | Context | Use Case |
|
||||
|------|------|---------|----------|
|
||||
| `notes:toolbar-actions` | notes | noteId, getContent, setContent | Dictation, AI tools, formatting |
|
||||
| `notes:note-footer` | notes | noteId, content | Related items, AI summary, metadata |
|
||||
| `chat:composer-tools` | chat | conversationId, insertText | Image gen, file attach, slash commands |
|
||||
| `chat:message-actions` | chat | messageId, content, attachments | Reactions, translate, bookmark |
|
||||
| `chat:image-actions` | chat | imageUrl, messageId, metadata | Regen, edit, upscale, style transfer |
|
||||
| `schedules:event-actions` | schedules | eventId, event | Add to calendar, share, convert to task |
|
||||
| `admin:package-actions` | admin | packageId, package | Custom admin tools per package |
|
||||
|
||||
Each host surface adds `sw.slots.renderAll()` calls at the appropriate
|
||||
locations. Extensions contribute via `sw.slots.register()` in their JS.
|
||||
The manifest `contributes` field makes the relationship visible to admins.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Slot schema validation.** Currently context contracts are documentation
|
||||
only. A runtime assertion mode (dev only) could warn when a contributor
|
||||
receives unexpected props. Deferred — convention is sufficient pre-1.0.
|
||||
|
||||
- **Slot visibility controls.** An admin might want to disable a specific
|
||||
contribution without disabling the entire contributing extension. A
|
||||
per-contribution enable/disable toggle in the admin UI. Deferred —
|
||||
extension enable/disable is the current granularity.
|
||||
|
||||
- **Cross-surface slot contributions.** An extension contributing to
|
||||
`notes:toolbar-actions` AND `chat:message-actions` with the same
|
||||
underlying logic but different UI. The `contributes` field already
|
||||
supports multiple entries. The extension's JS registers into both
|
||||
slots with slot-appropriate components.
|
||||
|
||||
- **Backend event subscriptions between extensions.** Currently extensions
|
||||
react to kernel events via `hooks`. Extension-to-extension events
|
||||
(e.g., "image-gen completed" → "chat refreshes") flow through the
|
||||
existing event bus — the generating extension's api_route publishes
|
||||
via `realtime.publish()`, the consuming surface listens via
|
||||
`sw.realtime.subscribe()`. No new primitive needed.
|
||||
744
docs/DESIGN-notes-v011x.md
Normal file
@@ -0,0 +1,744 @@
|
||||
# DESIGN: Notes Reference Extension — v0.11.x
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Purpose
|
||||
|
||||
Notes becomes the first **reference extension** — a first-party package
|
||||
that exercises every kernel primitive and proves the platform is capable
|
||||
of delivering a production-quality application. This is not a toy demo;
|
||||
it is the answer to "what can you build on Armature?"
|
||||
|
||||
The v0.11.x series takes the existing notes package (folders, tags,
|
||||
wikilinks, graph, CM6 editor, three view modes) and builds it into a
|
||||
full-featured knowledge base comparable to Obsidian, Notion, or Logseq —
|
||||
but running on Armature's extension architecture.
|
||||
|
||||
**The UI must be clean, inviting, and genuinely enjoyable to use.** The
|
||||
current notes surface is functional but visually mechanical — no
|
||||
personality, no micro-interactions, no sense of craft. If the reference
|
||||
extension feels like a developer prototype, the platform pitch fails
|
||||
regardless of how many kernel primitives it exercises. v0.11.0 is a
|
||||
full visual redesign before any feature work begins, and every subsequent
|
||||
version ships with UX quality built in, not bolted on.
|
||||
|
||||
### What Notes Exercises
|
||||
|
||||
| Kernel Primitive | How Notes Uses It |
|
||||
|-----------------|-------------------|
|
||||
| `db.write` | Notes, folders, tags, links, shares tables |
|
||||
| `sw.renderers` | Markdown rendering pipeline, custom block renderers |
|
||||
| `sw.panels` | `notes.reference` panel (from v0.10.4) |
|
||||
| `sw.slots` | Declares `notes:toolbar-actions`, `notes:note-footer` for extension composition |
|
||||
| `sw.events` | Realtime updates, panel ↔ surface communication |
|
||||
| `sw.markdown` | Unified markdown rendering with wikilink extensions |
|
||||
| `sw.shell.topbar` | Topbar slots for navigation context |
|
||||
| `sw.storage` | Editor state, sidebar collapse, preferences |
|
||||
| `sw.actions` | Exports `notes.create`, `notes.search` for cross-package calls |
|
||||
| Resource grants | Note and folder sharing via kernel permission model |
|
||||
| Starlark sandbox | All backend logic in `script.star` |
|
||||
| Settings cascade | Per-user editor mode, default view, daily note folder |
|
||||
| Surfaces | Multi-surface: full editor + public read-only surface |
|
||||
|
||||
---
|
||||
|
||||
## What Already Exists (v0.9.0)
|
||||
|
||||
### Frontend (1,806 lines — `js/main.js`)
|
||||
|
||||
- **NotesApp** — main shell with sidebar + editor layout
|
||||
- **FolderTree / FolderNode** — tree with expand/collapse, context menu,
|
||||
drag-and-drop note→folder, rename, create sub-folder (schema has
|
||||
`parent_id`, UI renders depth)
|
||||
- **NoteCard** — list items with title, snippet, date, tags, drag handle
|
||||
- **TagInput / TagFilter** — tag CRUD with autocomplete
|
||||
- **EditorPane** — three view modes (rendered / edit / split), CodeMirror
|
||||
6 integration with textarea fallback, auto-save, frontmatter parsing
|
||||
- **BacklinksPanel** — lists notes that link to the current note
|
||||
- **SidebarTabs** — Notes / Outline tabs
|
||||
- **SidebarOutline** — heading tree parsed from markdown body
|
||||
- **GraphPane** — canvas-based force-directed graph with folder coloring,
|
||||
orphan hiding, focus mode, pan/zoom
|
||||
- **Export** — download note as `.md` with frontmatter
|
||||
|
||||
### Backend (23K — `script.star`)
|
||||
|
||||
- Full CRUD: notes, folders, tags
|
||||
- Wikilink extraction (`_extract_wikilinks`) and link resolution
|
||||
(`_sync_links`) on every note save
|
||||
- Links / backlinks queries
|
||||
- Graph data endpoint (nodes + edges)
|
||||
- Search (title + body substring)
|
||||
- Stats endpoint
|
||||
- Folder CRUD with `parent_id`
|
||||
|
||||
### Schema (4 tables)
|
||||
|
||||
- `notes` — title, body, folder_id, creator_id, updated_at, pinned, archived
|
||||
- `tags` — note_id, tag
|
||||
- `links` — source_id, target_id, link_text
|
||||
- `folders` — name, parent_id, creator_id, sort_order
|
||||
|
||||
### Current CSS (795 lines — `css/main.css`)
|
||||
|
||||
Functional but visually flat. No animations, no keyframes, no
|
||||
micro-interactions. Identical `transition: var(--transition)` on every
|
||||
hover. No typographic hierarchy beyond font-size/weight. No visual
|
||||
rhythm. Sidebar and editor feel like admin panels, not a writing tool.
|
||||
|
||||
---
|
||||
|
||||
## Version Plan
|
||||
|
||||
### v0.11.0 — UI/UX Foundation
|
||||
|
||||
**Goal:** Complete visual redesign of the notes surface. Every pixel
|
||||
intentional. Every interaction feels crafted. This is the foundation
|
||||
that all subsequent feature versions build on.
|
||||
|
||||
This is NOT a "make it pretty" pass on the existing CSS. It is a
|
||||
ground-up rethink of the notes UI with the understanding that this is
|
||||
the first thing people see when evaluating what Armature can do.
|
||||
|
||||
**Design principles for notes:**
|
||||
|
||||
- **Writing-first.** The editor area dominates. Generous whitespace.
|
||||
Nothing competes for attention with the content the user is writing.
|
||||
- **Quiet chrome, loud content.** Sidebar, toolbar, and navigation fade
|
||||
into the background. The note body is the visual anchor — clean
|
||||
typography, comfortable line height, readable measure (60–80 chars).
|
||||
- **Progressive disclosure.** Folder tree, tags, backlinks, graph — all
|
||||
there but not all visible simultaneously. Contextual — show what's
|
||||
relevant to what the user is doing right now.
|
||||
- **Micro-interactions that feel alive.** Folder expand/collapse with
|
||||
rotation animation. Note cards with subtle lift on hover. Smooth
|
||||
sidebar resize. Mode transitions that animate, not snap. Save
|
||||
indicator that pulses, not just appears.
|
||||
- **Personality without kitsch.** The UI should feel like a well-designed
|
||||
indie app, not a Material Design template and not a Bootstrap theme.
|
||||
|
||||
**Specific deliverables:**
|
||||
|
||||
**Typography overhaul:**
|
||||
- Rendered note body: system serif stack for body text (Georgia, serif
|
||||
fallback) at 16px/1.7 line height. Headings in the system sans stack.
|
||||
Comfortable reading measure — `max-width: 720px` centered in the
|
||||
editor area with generous padding. Code blocks with distinct
|
||||
background and a monospace stack.
|
||||
- Editor (CM6): matching font size and line height so switching between
|
||||
rendered/edit mode doesn't jar. CM6 theme tokens aligned with note
|
||||
design tokens.
|
||||
- Sidebar text: smaller (13px), tighter, utility font. Clear hierarchy
|
||||
between folder names, note titles, snippet text, dates.
|
||||
|
||||
**Sidebar redesign:**
|
||||
- **Resizable** — drag handle between sidebar and editor. Width persisted.
|
||||
Smooth resize with no layout jank.
|
||||
- **Collapsible** — collapse to a thin icon strip (folder + search +
|
||||
graph icons) on narrow viewports or by user choice. Expand on hover
|
||||
or click. Collapse state persisted.
|
||||
- **Folder tree polish:** Indent guides (subtle vertical lines connecting
|
||||
parent→child). Folder icons that change on expand (open folder / closed
|
||||
folder, not just a triangle). Drop target highlighting with animation
|
||||
(not just background color change). Smooth height animation on
|
||||
expand/collapse.
|
||||
- **Note cards:** Subtle left border color-coded by folder (pulls from a
|
||||
soft palette, not harsh primaries). Title, snippet, relative date
|
||||
("3h ago" not "2026-04-03T12:34"). Tag pills with rounded, muted
|
||||
styling. Pin indicator as a subtle icon, not a text label.
|
||||
- **Search:** Inline search with clear button. Results highlight matching
|
||||
text. Smooth appear/disappear.
|
||||
|
||||
**Editor redesign:**
|
||||
- **Header:** Title input styled as a large heading (not an input field
|
||||
with a border). Folder breadcrumb below title in muted text. Toolbar
|
||||
actions as icon buttons with tooltips — no text labels cluttering the
|
||||
header. Save status indicator (saved ✓ / saving… / unsaved •) as a
|
||||
small, elegant badge.
|
||||
- **Mode switcher:** Segmented control (Read / Edit / Split) replacing
|
||||
the cycling button. Clear visual state.
|
||||
- **Rendered view:** Clean markdown rendering with proper spacing between
|
||||
elements. Block quotes with a left accent border. Tables with subtle
|
||||
borders and alternating row tinting. Inline code with pill-style
|
||||
background. Links with underline on hover only.
|
||||
- **Empty editor state:** "Select a note or create a new one" with a
|
||||
softly illustrated empty state — not a plain text message.
|
||||
|
||||
**Graph visual refresh:**
|
||||
- Dark-on-light node rendering with soft shadows (not flat circles with
|
||||
outlines). Node labels that appear on hover with smooth fade-in.
|
||||
Edge rendering with slight curves (not straight lines). Background
|
||||
subtle dot grid.
|
||||
|
||||
**Transitions and animations:**
|
||||
- Sidebar expand/collapse: 200ms ease-out slide.
|
||||
- Folder tree expand/collapse: 150ms height animation with children
|
||||
fading in.
|
||||
- Note card hover: subtle translateY(-1px) + box-shadow lift.
|
||||
- View mode switch: crossfade (100ms fade out → swap → 100ms fade in).
|
||||
- Save indicator: pulse animation on "saving", check mark with brief
|
||||
scale-up on "saved".
|
||||
- Graph node hover: scale(1.2) with spring easing.
|
||||
- Panel/dialog open: 150ms slide-up + fade-in (consistent with kernel
|
||||
Dialog animation).
|
||||
|
||||
**Color and theming:**
|
||||
- Notes should work beautifully in both light and dark themes.
|
||||
Use CSS custom properties (already the pattern via `var(--bg-surface)`,
|
||||
etc.) but add notes-specific tokens for accent colors, folder palette,
|
||||
and typography:
|
||||
```css
|
||||
--notes-body-font: Georgia, 'Times New Roman', serif;
|
||||
--notes-body-size: 16px;
|
||||
--notes-body-line-height: 1.7;
|
||||
--notes-body-measure: 720px;
|
||||
--notes-accent: var(--accent);
|
||||
--notes-folder-1: #6366f1; /* indigo */
|
||||
--notes-folder-2: #8b5cf6; /* violet */
|
||||
--notes-folder-3: #ec4899; /* pink */
|
||||
--notes-folder-4: #f59e0b; /* amber */
|
||||
--notes-folder-5: #10b981; /* emerald */
|
||||
--notes-folder-6: #06b6d4; /* cyan */
|
||||
```
|
||||
|
||||
**Responsive:**
|
||||
- Below 768px: sidebar collapses to overlay (slide-in from left).
|
||||
Editor goes full-width. Split view disabled. Touch-friendly tap
|
||||
targets (44px minimum on all interactive elements).
|
||||
- Between 768px and 1024px: sidebar narrower (220px). Editor gets
|
||||
remaining space.
|
||||
- Above 1024px: full layout with comfortable sidebar width.
|
||||
|
||||
**What this version does NOT change:**
|
||||
- No new features. Same note CRUD, same folders, same tags, same
|
||||
wikilinks, same graph, same three view modes.
|
||||
- No backend changes. No schema changes.
|
||||
- The existing JS components are restructured for the new layout but
|
||||
retain their current behavior.
|
||||
|
||||
**Deliverable:** Complete CSS rewrite (`css/main.css`), targeted JS
|
||||
changes for new layout structure (resizable sidebar, collapsible sidebar,
|
||||
mode switcher component, animation hooks), and updated component
|
||||
templates where the HTML structure needs to change for the new design.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.1 — Deep Folders + Navigation
|
||||
|
||||
**Goal:** Folders become a real hierarchy with breadcrumbs, not just a
|
||||
flat tree with parent_id.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Breadcrumb navigation** in editor header showing the folder path.
|
||||
Click any segment to navigate to that folder's note list. Styled
|
||||
consistently with the v0.11.0 muted-text breadcrumb design.
|
||||
- **Drag folder→folder** to reparent. Animated drop indicator shows
|
||||
nesting target with indent guide preview. Depth limit: 5 levels.
|
||||
- **Folder sort** — drag to reorder within a level. Smooth reorder
|
||||
animation. Persists via `sort_order` column (already in schema).
|
||||
- **Collapse/expand persistence** — expanded folder set stored in
|
||||
`sw.storage` keyed by user.
|
||||
- **Move dialog** — select destination folder from a tree picker when
|
||||
moving notes (alternative to drag for accessibility / many folders).
|
||||
Uses `sw.ui.Dialog` with the new folder tree component inside.
|
||||
|
||||
**UX standard:** All folder interactions use the animation language
|
||||
established in v0.11.0. Drop targets highlight with the folder color
|
||||
from the palette. Reparent shows a brief connection-line animation.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
- Validate `parent_id` chain on create/update (no cycles, depth ≤ 5).
|
||||
- Folder delete: require empty or offer cascade (move children to parent,
|
||||
or move contained notes to Unfiled).
|
||||
- `_list_folders()` returns full tree with `children_count` and
|
||||
`note_count` for each folder.
|
||||
|
||||
**Schema:** No changes.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.2 — Wikilinks + Backlinks
|
||||
|
||||
**Goal:** `[[wikilinks]]` become a first-class editing and navigation
|
||||
primitive with autocomplete, previews, and unresolved link handling.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **CM6 autocomplete** — typing `[[` triggers a fuzzy note title picker
|
||||
with a clean dropdown styled consistently with the notes design
|
||||
language (not browser-default autocomplete). Shows note title, folder
|
||||
path, and snippet preview. Keyboard navigable.
|
||||
- **Wikilink rendering** — rendered markdown converts `[[Title]]` to
|
||||
internal links with a subtle notes-specific style (dotted underline,
|
||||
small link icon). Distinct from external URLs.
|
||||
- **Hover preview** — hovering a wikilink shows a floating card with
|
||||
the target note's title, first ~200 chars rendered as markdown, and
|
||||
folder/tag metadata. Smooth fade-in, positioned to avoid viewport
|
||||
overflow. Same visual language as graph node hover cards.
|
||||
- **Unresolved links** — `[[Nonexistent Note]]` renders with a dashed
|
||||
styling and a muted color. Click to create the note with that title
|
||||
(pre-filled). Small "+" indicator on hover.
|
||||
- **Backlinks panel improvements:**
|
||||
- Show context snippet (surrounding text with the `[[link]]`
|
||||
highlighted).
|
||||
- Group by folder with folder color indicators.
|
||||
- Count badge in sidebar tab (animated increment on new backlinks).
|
||||
- Navigate to the linking note and scroll to the link location.
|
||||
- **Aliases** — new `aliases` column on `notes` table. Autocomplete
|
||||
searches both title and aliases.
|
||||
|
||||
**UX standard:** Autocomplete dropdown uses the notes card styling.
|
||||
Hover preview card shares visual language with graph tooltips. Unresolved
|
||||
link creation is a smooth inline experience, not a navigate-away-and-back.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
- `_sync_links()` resolves aliases as well as titles.
|
||||
- New endpoint: `GET /autocomplete?q=...` — fast title+alias prefix
|
||||
search (limit 10).
|
||||
- Backlinks response includes `context_snippet` field.
|
||||
|
||||
**Schema changes:**
|
||||
|
||||
- `notes` table: add `aliases` column (text, comma-separated).
|
||||
|
||||
---
|
||||
|
||||
### v0.11.3 — Live Preview + Rich Editing
|
||||
|
||||
**Goal:** The editor becomes genuinely pleasant to write in — live
|
||||
preview that scrolls in sync, or an optional inline-formatted mode.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Split pane scroll sync** — scrolling the CM6 editor scrolls the
|
||||
rendered preview to the corresponding position. Uses heading anchors
|
||||
for coarse sync and line-height interpolation for fine sync.
|
||||
- **Live preview debounce** — rendered pane updates as you type with
|
||||
150ms debounce. Smooth content transitions (no jarring reflow).
|
||||
- **Inline preview** (Obsidian-style) — optional mode where markdown
|
||||
syntax hides and formatting appears inline while editing. CM6
|
||||
decorations for bold, italic, headings, links, code blocks.
|
||||
Syntax reappears when cursor enters the formatted region. This is
|
||||
the "fourth mode" alongside rendered / edit / split — a true
|
||||
WYSIWYG-ish editing experience without leaving markdown.
|
||||
- **Image paste** — paste from clipboard, upload via `files` module,
|
||||
insert `` at cursor. Progress indicator during upload.
|
||||
Fallback: base64 inline if `files` module unavailable.
|
||||
- **Table editing** — tab-to-next-cell, auto-expand columns, add
|
||||
row/column with floating buttons on hover. Markdown tables become
|
||||
usable instead of a formatting chore.
|
||||
|
||||
**UX standard:** Split pane has an animated gutter with a subtle drag
|
||||
handle. Mode switcher expands to accommodate the fourth option (or
|
||||
becomes a dropdown if space is tight). Image paste shows a smooth
|
||||
inline loading skeleton.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
- Image upload endpoint: `POST /images` — receives multipart, stores
|
||||
via `files` module, returns URL.
|
||||
|
||||
**Schema:** No changes.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.4 — Note Sharing + Permissions
|
||||
|
||||
**Goal:** Notes become collaborative. Share a note or folder with
|
||||
a team, group, individual, or the public — with read-only or read-write
|
||||
permissions.
|
||||
|
||||
**Share Model:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Share Scope │ Granularity │ Permissions │
|
||||
├──────────────────────────┼─────────────┼─────────────┤
|
||||
│ Public (anonymous URL) │ note only │ read-only │
|
||||
│ Team │ note/folder │ ro / rw │
|
||||
│ Group │ note/folder │ ro / rw │
|
||||
│ Individual (user) │ note/folder │ ro / rw │
|
||||
└──────────────────────────┴─────────────┴─────────────┘
|
||||
```
|
||||
|
||||
**Folder sharing inherits:** sharing a folder grants access to all notes
|
||||
in that folder (and sub-folders). Individual note shares override folder
|
||||
permissions (higher wins).
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Share dialog** — clean modal (using `sw.ui.Dialog`) with scope
|
||||
selector, user/team/group picker (using `sw.ui.UserPicker` or new
|
||||
entity picker), permission toggle, and copy-link button. Share URL
|
||||
preview with visual indicator.
|
||||
- **Public notes surface** — new surface at `/s/notes/public/:share_id`.
|
||||
Renders a single note in read-only mode with clean, minimal chrome.
|
||||
Uses the v0.11.0 typography and rendered view styling. Armature
|
||||
branding footer. No authentication required.
|
||||
- **Shared-with-me view** — sidebar section (new tab) showing notes/folders
|
||||
shared by others. Grouped by owner with avatar. Visual distinction
|
||||
from your own notes.
|
||||
- **Share indicators** — shared notes show a subtle share icon on the
|
||||
note card. Folder tree shows share badge on shared folders. Hover
|
||||
reveals share scope.
|
||||
- **Permission enforcement** — Starlark backend checks share permissions
|
||||
on every read/write.
|
||||
- **Share revocation** — remove access from share dialog with
|
||||
confirmation.
|
||||
|
||||
**UX standard:** Share dialog is the most complex new UI in the series
|
||||
and must feel simple. One-click sharing for common cases (share with
|
||||
team, make public). Advanced options (specific users, groups) are
|
||||
available but not in the way. Copy-link button shows a brief "Copied!"
|
||||
toast. Public note surface is a showcase — the best the notes rendering
|
||||
can look.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
- New `note_shares` table.
|
||||
- New `folder_shares` table.
|
||||
- Access check function: `_can_access(user_id, note_id, permission)`.
|
||||
- `_list_notes()` includes shared notes with `shared_by` metadata.
|
||||
- Public note endpoint: unauthenticated GET by share_token.
|
||||
|
||||
**Schema changes:**
|
||||
|
||||
- New table: `note_shares` — note_id, share_type, target_id, permission,
|
||||
share_token, created_by, created_at.
|
||||
- New table: `folder_shares` — folder_id, share_type, target_id,
|
||||
permission, share_token, created_by, created_at.
|
||||
|
||||
**Manifest changes:**
|
||||
|
||||
- New surface: `/notes/public/:share_id` with `auth: "public"`.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.5 — Graph + Outline Hardening
|
||||
|
||||
**Goal:** The graph becomes a genuine navigation and discovery tool.
|
||||
The outline becomes a reliable TOC.
|
||||
|
||||
**Graph improvements:**
|
||||
|
||||
- **d3-force layout** — replace hand-rolled force sim with d3-force.
|
||||
Better convergence, collision avoidance, centering. ~15KB optional
|
||||
vendor (same pattern as CM6).
|
||||
- **Zoom controls** — explicit +/- buttons (styled as floating pills) plus
|
||||
scroll-to-zoom. Reset-to-fit button.
|
||||
- **Minimap** — small overview in corner with viewport rectangle.
|
||||
Draggable to navigate large graphs.
|
||||
- **Cluster by folder** — visual grouping with soft convex hulls in
|
||||
folder colors (from the v0.11.0 palette). Toggle on/off.
|
||||
- **Filter by tag** — highlight matching nodes, dim others. Smooth
|
||||
opacity transition.
|
||||
- **Search in graph** — highlight and smooth-pan to matching nodes.
|
||||
- **Hover card** — note title, snippet, folder, tag count. Same visual
|
||||
language as wikilink hover preview (v0.11.2).
|
||||
|
||||
**Outline improvements:**
|
||||
|
||||
- **Scroll sync** — active heading highlights as user scrolls. Uses
|
||||
`IntersectionObserver`. Smooth highlight transition.
|
||||
- **Click-to-scroll** — smooth scroll with brief heading highlight pulse.
|
||||
- **Indent levels** — h1→h6 with subtle indent guides matching the
|
||||
folder tree style.
|
||||
- **Collapse/expand** — heading sections collapsible in outline.
|
||||
Animated, consistent with folder tree animations.
|
||||
|
||||
**UX standard:** The graph should feel like a discovery tool, not a tech
|
||||
demo. Interactions should be fluid — pan, zoom, hover, click — with no
|
||||
jank. The outline should feel like a table of contents in a well-typeset
|
||||
book.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.6 — Quick Switcher + Commands
|
||||
|
||||
**Goal:** Keyboard-driven navigation. Power users never touch the mouse.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Quick switcher** (`Cmd+O` / `Ctrl+O`) — centered overlay with fuzzy
|
||||
search. Clean design: large input field, results below with note
|
||||
title, folder path, and snippet. Recent notes above search results.
|
||||
Arrow keys + Enter. Smooth open/close animation. Matches the visual
|
||||
language of command palettes in VS Code / Raycast.
|
||||
- **Command palette** (`Cmd+Shift+P`) — same overlay style, listing all
|
||||
available note actions as searchable commands. Extensible via
|
||||
`sw.actions`.
|
||||
- **Keyboard shortcuts:** Registered via a central keymap.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Cmd+O` | Quick switcher |
|
||||
| `Cmd+Shift+P` | Command palette |
|
||||
| `Cmd+N` | New note |
|
||||
| `Cmd+S` | Save (in edit mode) |
|
||||
| `Cmd+E` | Toggle edit/rendered |
|
||||
| `Cmd+Shift+E` | Toggle split view |
|
||||
| `Cmd+D` | Open today's daily note |
|
||||
| `Cmd+G` | Toggle graph view |
|
||||
| `Cmd+B` | Toggle backlinks panel |
|
||||
| `Escape` | Close active panel/dialog |
|
||||
|
||||
**UX standard:** The quick switcher is a high-frequency interaction —
|
||||
it must open instantly (no perceptible delay), search results must
|
||||
appear as-you-type, and the whole flow (Cmd+O → type → Enter) should
|
||||
take under 2 seconds for a user who knows what they want.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.7 — Daily Notes + Templates
|
||||
|
||||
**Goal:** Recurring note patterns that lower friction.
|
||||
|
||||
**Daily notes:**
|
||||
|
||||
- Auto-created daily note — navigating to "today" creates a note titled
|
||||
`YYYY-MM-DD` in a configurable daily notes folder.
|
||||
- Daily note template — configurable template body.
|
||||
- **Calendar picker** — small, beautiful calendar widget in the sidebar.
|
||||
Days with notes dot-marked. Styled as a subtle, compact component
|
||||
(not a full-page calendar). Click a date to open that day's note.
|
||||
- Previous/next day nav arrows in editor header for daily notes.
|
||||
|
||||
**Templates:**
|
||||
|
||||
- Notes in a "Templates" folder are templates. No special type.
|
||||
- "New Note" dropdown offers template selection with preview.
|
||||
- Template variables: `{{date}}`, `{{time}}`, `{{title}}`, `{{folder}}` —
|
||||
simple string replacement.
|
||||
- **Slash commands** in CM6 — typing `/` at line start shows a styled
|
||||
command menu: `/template`, `/date`, `/time`, `/todo`, `/callout`,
|
||||
`/table`, `/code`, `/divider`. Extensible via `sw.slots`.
|
||||
|
||||
**UX standard:** The calendar picker should feel like a subtle,
|
||||
integrated part of the sidebar — not a jarring widget. Template
|
||||
selection should show a live preview of the template content.
|
||||
Slash commands should appear fast and be keyboard navigable.
|
||||
|
||||
**Settings additions:**
|
||||
|
||||
- `daily_note_folder` — folder name for daily notes.
|
||||
- `daily_note_template` — template body.
|
||||
- `template_folder` — folder name for templates.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.8 — Transclusion + Embeds
|
||||
|
||||
**Goal:** Notes reference and embed each other's content.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Transclusion** (`![[Note Title]]`) — embeds referenced note content
|
||||
inline in a clean bordered container with source title as header link.
|
||||
- **Section transclusion** (`![[Note Title#Heading]]`) — embeds content
|
||||
under specified heading only.
|
||||
- **Block transclusion** (`![[Note Title^block-id]]`) — single paragraph
|
||||
by block ID.
|
||||
- **Recursion guard** — depth limited to 3. Circular references show
|
||||
a clean warning card, not an error.
|
||||
- **File attachments** — drag-and-drop files onto editor to upload.
|
||||
Non-image files render as styled download cards (file icon, name,
|
||||
size).
|
||||
- **Embed preview in editor** — CM6 decoration shows read-only preview
|
||||
below `![[...]]` line. Collapsible with smooth animation.
|
||||
|
||||
**UX standard:** Transclusions should feel like natural parts of the
|
||||
document, not foreign inclusions. The border and header link should be
|
||||
subtle. Embedded content uses the same typography as the host note.
|
||||
Collapse animation is smooth.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.9 — Composability: Slots + Actions
|
||||
|
||||
**Goal:** Notes becomes a host surface that other extensions enhance.
|
||||
|
||||
**Slot declarations:**
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": {
|
||||
"notes:toolbar-actions": {
|
||||
"description": "Action buttons in the note editor toolbar",
|
||||
"context": {
|
||||
"noteId": "string",
|
||||
"getContent": "function — returns markdown body",
|
||||
"setContent": "function — replaces markdown body",
|
||||
"getTitle": "function — returns title"
|
||||
}
|
||||
},
|
||||
"notes:note-footer": {
|
||||
"description": "Content rendered below the note body",
|
||||
"context": { "noteId": "string", "content": "string — HTML" }
|
||||
},
|
||||
"notes:sidebar-tabs": {
|
||||
"description": "Additional tabs in the notes sidebar",
|
||||
"context": { "activeNoteId": "string or null" }
|
||||
},
|
||||
"notes:slash-commands": {
|
||||
"description": "Additional slash commands in the editor",
|
||||
"context": { "insertText": "function(text)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Exported actions:**
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
"actions": {
|
||||
"notes.create": "Create note (params: title, body, folder_id)",
|
||||
"notes.search": "Search notes (params: query, limit)",
|
||||
"notes.get": "Get note by ID (params: note_id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UX standard:** Slot contributions must fit visually. Toolbar action
|
||||
buttons contributed by other extensions inherit the notes icon-button
|
||||
styling. Sidebar tabs contributed by extensions match the built-in tab
|
||||
design. The notes surface should not look different when extensions
|
||||
contribute to its slots.
|
||||
|
||||
---
|
||||
|
||||
### v0.11.10 — Panel Enhancement + Quality Gate
|
||||
|
||||
**Goal:** The `notes.reference` panel (shipped in v0.10.4) inherits
|
||||
the v0.11.x features. Final quality gate.
|
||||
|
||||
**Panel updates:**
|
||||
|
||||
- Reference panel gets the v0.11.0 visual design language.
|
||||
- Search, folder filter, tag filter in panel.
|
||||
- Preview pane shows rendered note content (v0.11.0 typography).
|
||||
- Click-to-insert: emits `panel.notes.reference.selected` for host.
|
||||
- Wikilink resolution works within panel preview.
|
||||
|
||||
**Quality gate criteria:**
|
||||
|
||||
- All v0.11.x features exercised in SDK test runner.
|
||||
- Notes-runner surface tests cover: CRUD, sharing, wikilinks, daily
|
||||
notes, templates, transclusion.
|
||||
- Graph renders correctly with 200+ nodes at 60fps.
|
||||
- Public note surface renders without authentication.
|
||||
- At least one slot contribution demonstrated.
|
||||
- Panel reference works in at least one other surface.
|
||||
- **UX review:** every screen, every interaction, every empty state
|
||||
reviewed against v0.11.0 design principles. No "placeholder" UI
|
||||
surviving to this point.
|
||||
- **Responsive:** full experience on mobile (sidebar overlay, touch
|
||||
targets, no horizontal scroll).
|
||||
- **Accessibility:** keyboard navigation through all views. ARIA labels.
|
||||
Focus management on panel/dialog open/close.
|
||||
- **Performance:** virtual scrolling on note list for 1000+ notes.
|
||||
Graph 60fps with 500+ nodes. Lazy-load graph and CM6.
|
||||
|
||||
---
|
||||
|
||||
## Uniquely Armature
|
||||
|
||||
| Feature | Obsidian/Notion | Armature Notes |
|
||||
|---------|-----------------|----------------|
|
||||
| Extension slots | Plugin API (Obsidian) | Kernel-level composability — any package contributes to any slot without coupling |
|
||||
| Sharing | Obsidian Publish (paid), Notion sharing | Built on kernel resource model — same permission patterns as every other extension |
|
||||
| Panels | N/A | Notes is a panel provider — embed a notes view inside chat, dashboard, or any surface |
|
||||
| Multi-surface | Single window | Full surface + public surface + reference panel, all from one package |
|
||||
| Server-side logic | Local only (Obsidian) | Starlark backend — search, link resolution, access control run server-side |
|
||||
| Realtime | N/A (Obsidian), built-in (Notion) | Via `sw.realtime` — same primitive available to all extensions |
|
||||
| Cross-extension actions | Plugin API | `sw.actions` — any package can call `notes.create` or `notes.search` |
|
||||
| Self-hosted | Obsidian Sync (paid) | Runs on your infrastructure, your data, your rules |
|
||||
|
||||
The thesis: **Notes isn't just a note-taking app — it's proof that
|
||||
Armature's extension architecture can deliver a production-quality
|
||||
knowledge base that competes with purpose-built SaaS tools, while
|
||||
remaining decomposable and extensible.** And it has to *look and feel*
|
||||
like it competes, not just architecturally compete.
|
||||
|
||||
---
|
||||
|
||||
## Schema Summary
|
||||
|
||||
### Existing tables (no changes)
|
||||
|
||||
- `notes` — title, body, folder_id, creator_id, updated_at, pinned, archived
|
||||
- `tags` — note_id, tag
|
||||
- `links` — source_id, target_id, link_text
|
||||
- `folders` — name, parent_id, creator_id, sort_order
|
||||
|
||||
### New columns
|
||||
|
||||
- `notes.aliases` (text) — comma-separated aliases (v0.11.2)
|
||||
|
||||
### New tables
|
||||
|
||||
- `note_shares` — note_id, share_type, target_id, permission, share_token, created_by, created_at (v0.11.4)
|
||||
- `folder_shares` — folder_id, share_type, target_id, permission, share_token, created_by, created_at (v0.11.4)
|
||||
|
||||
---
|
||||
|
||||
## Settings Summary
|
||||
|
||||
### Existing
|
||||
|
||||
- `default_view` — "recent" or "pinned"
|
||||
- `editor_mode` — "rendered", "edit", or "split"
|
||||
|
||||
### New
|
||||
|
||||
| Setting | Type | Default | Version |
|
||||
|---------|------|---------|---------|
|
||||
| `daily_note_folder` | string | "Daily Notes" | v0.11.7 |
|
||||
| `daily_note_template` | string | `"# {{date}}\n\n"` | v0.11.7 |
|
||||
| `template_folder` | string | "Templates" | v0.11.7 |
|
||||
| `inline_preview` | boolean | false | v0.11.3 |
|
||||
| `graph_hide_orphans` | boolean | false | v0.11.5 |
|
||||
| `graph_cluster_by` | string | "folder" | v0.11.5 |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| UI redesign as v0.11.0, before features | Every subsequent version builds on the visual foundation. Shipping features on an ugly base means reworking the UI of every feature when the redesign eventually happens. Front-loading avoids double work and ensures every version screenshot looks like a real product. |
|
||||
| Notes as first reference extension | Exercises more kernel primitives than any other candidate. Complex enough to prove the platform; familiar enough to be immediately useful. |
|
||||
| Dedicated v0.11.x series (11 versions) | Notes is too large for a single version. Each v0.11.x is independently shippable and CI-green. |
|
||||
| UX quality threaded through every version | Each version specifies its UX standard, not just functionality. No "make it pretty later" — every feature ships with its final visual quality. |
|
||||
| Share model via new tables | Notes sharing needs note/folder-specific semantics (inheritance, public URLs, share tokens). Purpose-built tables beat generic resource grants for this use case. |
|
||||
| Aliases as comma-separated text | Small set (1–3 per note). Separate table adds join complexity for marginal normalization benefit. |
|
||||
| d3-force for graph | Existing hand-rolled sim has poor convergence at 100+ nodes. d3-force is battle-tested, ~15KB. |
|
||||
| Transclusion rendered on view, not live-synced | Live-sync requires WebSocket subs per embedded note. Disproportionate complexity pre-MVP. |
|
||||
| Templates are just notes in a folder | No template schema. KISS. |
|
||||
| Inline preview as fourth editing mode | Obsidian's killer UX feature. Users who want WYSIWYG-ish editing without leaving markdown get it without a ProseMirror dependency — CM6 decorations handle it. |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
v0.11.0 UI/UX Foundation ← EVERYTHING depends on this
|
||||
v0.11.1 Deep Folders ← foundation for folder sharing (v0.11.4)
|
||||
v0.11.2 Wikilinks ← foundation for transclusion (v0.11.8)
|
||||
v0.11.3 Live Preview ← foundation for inline preview + embeds (v0.11.8)
|
||||
v0.11.4 Sharing ← depends on deep folders (v0.11.1)
|
||||
v0.11.5 Graph + Outline ← independent (hardening existing features)
|
||||
v0.11.6 Quick Switcher ← independent (keyboard navigation)
|
||||
v0.11.7 Daily Notes ← depends on deep folders (v0.11.1) for daily folder
|
||||
v0.11.8 Transclusion ← depends on wikilinks (v0.11.2) + live preview (v0.11.3)
|
||||
v0.11.9 Composability ← independent (slot/action declarations)
|
||||
v0.11.10 Polish + Gate ← depends on all above
|
||||
```
|
||||
|
||||
Versions v0.11.5, v0.11.6, and v0.11.9 are independent and can be
|
||||
reordered if priorities shift.
|
||||
743
docs/DESIGN-panels.md
Normal file
@@ -0,0 +1,743 @@
|
||||
# DESIGN: Panels — v0.10.x
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Packages export one rendering granularity today: a **surface** — a
|
||||
full-page application that owns `#extension-mount`. There is no way
|
||||
for a package to offer a lightweight, composable view of itself that
|
||||
another surface can pull in.
|
||||
|
||||
Concrete example: the `notes` package provides a full note editor
|
||||
surface at `/s/notes`. When a user is in the `chat` surface and wants
|
||||
to reference their notes, the only option is to navigate away. There is
|
||||
no mechanism to embed a notes panel alongside chat, whether as a
|
||||
floating window, a docked sidebar, or a bottom strip.
|
||||
|
||||
This forces users into one-thing-at-a-time workflows and prevents the
|
||||
"extensions extending extensions" composability story from reaching the
|
||||
UI layer. Slots and actions allow injection of buttons and menu items,
|
||||
but not entire companion views.
|
||||
|
||||
Without kernel coordination, package authors who need this will roll
|
||||
their own floating containers, producing:
|
||||
|
||||
- **Z-index wars** between packages and kernel overlays (Dialog at 1000,
|
||||
debug at 9999, toast in between).
|
||||
- **Inconsistent drag/resize** behavior (touch support, bounds clamping,
|
||||
accessibility).
|
||||
- **No position persistence** — panels reset on every navigation.
|
||||
- **No layout negotiation** — docked panels can't tell the host surface
|
||||
to shrink.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Window management system.** This is not a tiling WM. The kernel
|
||||
provides a small set of presentation modes (floating, docked). Complex
|
||||
layouts are post-1.0 horizon.
|
||||
- **Cross-surface panel sharing at runtime.** A panel runs inside one
|
||||
host surface's page context. It is not an iframe or a separate
|
||||
browsing context.
|
||||
- **Panel-to-panel communication.** Panels communicate with their host
|
||||
surface (and each other) via `sw.events`. No new IPC mechanism.
|
||||
- **Server-side panel rendering.** Panels are frontend-only. The kernel
|
||||
resolves dependencies and serves JS; rendering happens in the browser.
|
||||
|
||||
## What Already Exists
|
||||
|
||||
**Three rendering tiers exist today; panels fill the gap between the
|
||||
first two:**
|
||||
|
||||
| Tier | Scope | Example |
|
||||
|------|-------|---------|
|
||||
| **Surface** | Full-page app, owns `#extension-mount` | Notes editor, Chat, Admin |
|
||||
| *(gap)* | *Composable companion view* | *Notes reference panel in Chat* |
|
||||
| **Block renderer** | Inline content unit in markdown | Mermaid diagram, KaTeX formula |
|
||||
|
||||
**Kernel primitives that panels build on:**
|
||||
|
||||
- `sw.ui.Dialog` — centered modal with focus trap, backdrop, z-index 1000.
|
||||
- `sw.ui.Drawer` — slide-in side panel (left/right), backdrop, z-index 1000.
|
||||
- `sw.events` — pub/sub event bus, local and realtime. Already the
|
||||
communication channel between surfaces and slot contributors.
|
||||
- `sw.storage` — per-user localStorage wrapper with namespaced keys.
|
||||
- `sw.slots` — named UI injection points with manifest declarations.
|
||||
- Manifest `depends` field — package dependency resolution at install time.
|
||||
|
||||
**What panels add:** A second rendering granularity between surfaces and
|
||||
block renderers, with kernel-managed presentation modes and lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Three-Tier Rendering Hierarchy
|
||||
|
||||
After this change, every package can export up to three things:
|
||||
|
||||
```
|
||||
Surface — full-page app, owns the mount, has shell topbar slots
|
||||
Panel — composable view, kernel-managed container, presentation-agnostic
|
||||
Renderer — inline content block (sw.renderers)
|
||||
```
|
||||
|
||||
The key architectural property: **the panel component does not know its
|
||||
presentation mode.** It renders into whatever container the kernel
|
||||
provides. Floating, docked-right, docked-bottom — that is a shell/layout
|
||||
concern. The panel receives a mount element and a size. Nothing else.
|
||||
|
||||
### 2. Manifest Schema
|
||||
|
||||
#### Provider: `panels` field
|
||||
|
||||
The package that provides a panel declares it in its manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"surfaces": ["/notes"],
|
||||
"panels": {
|
||||
"reference": {
|
||||
"entry": "js/panels/reference.js",
|
||||
"title": "Notes",
|
||||
"icon": "📝",
|
||||
"description": "Searchable note list with quick preview",
|
||||
"min_width": 280,
|
||||
"min_height": 200,
|
||||
"default_width": 400,
|
||||
"default_height": 350
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Panel IDs are namespaced: `<package_id>.<panel_key>`. The notes
|
||||
reference panel above is addressed as `notes.reference` everywhere
|
||||
in the SDK and in consumer manifests.
|
||||
|
||||
A package may declare multiple panels:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"panels": {
|
||||
"reference": { "entry": "js/panels/reference.js", ... },
|
||||
"graph": { "entry": "js/panels/graph.js", ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Consumer: `panels` dependency
|
||||
|
||||
The consuming surface declares which panels it wants available:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat",
|
||||
"surfaces": ["/chat"],
|
||||
"panels": ["notes.reference"]
|
||||
}
|
||||
```
|
||||
|
||||
This is a **soft dependency**: the panel is available if the providing
|
||||
package is installed and enabled. If not, `sw.panels.open()` returns
|
||||
`false` and the consuming surface can degrade gracefully (hide the
|
||||
button, show a tooltip explaining the missing package).
|
||||
|
||||
This is distinct from `depends` (hard dependency — install fails without
|
||||
it). A consumer does not need the provider installed to function; the
|
||||
panel is an enhancement.
|
||||
|
||||
### 3. Panel Entry Point Contract
|
||||
|
||||
The panel JS module exports a single `mount` function. The kernel calls
|
||||
it with a DOM element and a context object. It returns a cleanup function.
|
||||
|
||||
```js
|
||||
// notes/js/panels/reference.js
|
||||
const { html } = window;
|
||||
const { render } = preact;
|
||||
|
||||
export function mount(el, ctx) {
|
||||
// ctx.sw — SDK instance
|
||||
// ctx.params — optional params from sw.panels.open()
|
||||
// ctx.panelId — 'notes.reference'
|
||||
// ctx.close — function to request close
|
||||
// ctx.resize — function(width, height) to request resize
|
||||
|
||||
render(html`<${NotesReference} sw=${ctx.sw} />`, el);
|
||||
|
||||
// Return cleanup
|
||||
return () => render(null, el);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- The panel must not assume any particular container size. Use CSS
|
||||
`width: 100%; height: 100%` and let the kernel container control
|
||||
dimensions.
|
||||
- The panel must not create its own overlay, backdrop, or drag handle.
|
||||
The kernel wraps the panel in the appropriate chrome.
|
||||
- The panel must not set `z-index` on anything. The kernel manages
|
||||
stacking.
|
||||
- The panel may use `ctx.close()` to request its own dismissal (e.g.
|
||||
user clicks an "X" inside the panel content area).
|
||||
- The panel may use `ctx.resize(w, h)` to suggest a new size, but the
|
||||
kernel may clamp or ignore the request.
|
||||
- Communication with the host surface is via `sw.events` — the same
|
||||
bus surfaces and slot contributors already use.
|
||||
|
||||
### 4. SDK API — `sw.panels`
|
||||
|
||||
New SDK module: `src/js/sw/sdk/panels.js`
|
||||
|
||||
```js
|
||||
export function createPanels(events, storage) {
|
||||
const _registry = new Map(); // panelId → manifest entry
|
||||
const _active = new Map(); // panelId → { el, cleanup, mode, ... }
|
||||
|
||||
return {
|
||||
/**
|
||||
* Register a panel from manifest data. Called by the kernel
|
||||
* during surface load — not by package code directly.
|
||||
*/
|
||||
_register(panelId, manifest) { ... },
|
||||
|
||||
/**
|
||||
* Open a panel. Lazy-loads the JS entry if not yet loaded.
|
||||
*
|
||||
* @param {string} panelId — e.g. 'notes.reference'
|
||||
* @param {object} opts
|
||||
* @param {string} opts.mode — 'floating' | 'docked-right' | 'docked-left' | 'docked-bottom'
|
||||
* @param {object} opts.params — passed to mount(el, ctx)
|
||||
* @param {number} opts.width — override default width
|
||||
* @param {number} opts.height — override default height
|
||||
* @returns {Promise<boolean>} — false if panel not available
|
||||
*/
|
||||
async open(panelId, opts = {}) { ... },
|
||||
|
||||
/**
|
||||
* Close a panel. Calls cleanup, removes container.
|
||||
*/
|
||||
close(panelId) { ... },
|
||||
|
||||
/**
|
||||
* Toggle open/close.
|
||||
*/
|
||||
toggle(panelId, opts = {}) { ... },
|
||||
|
||||
/**
|
||||
* Check if a panel is currently open.
|
||||
*/
|
||||
isOpen(panelId) { ... },
|
||||
|
||||
/**
|
||||
* Check if a panel is available (provider installed + enabled).
|
||||
*/
|
||||
isAvailable(panelId) { ... },
|
||||
|
||||
/**
|
||||
* List available panel IDs for the current surface.
|
||||
*/
|
||||
list() { ... },
|
||||
|
||||
/**
|
||||
* List currently open panel IDs.
|
||||
*/
|
||||
active() { ... },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Exposed on `sw` as:**
|
||||
|
||||
```js
|
||||
sw.panels.open('notes.reference', { mode: 'floating' });
|
||||
sw.panels.open('notes.reference', { mode: 'docked-right' });
|
||||
sw.panels.close('notes.reference');
|
||||
sw.panels.toggle('notes.reference');
|
||||
sw.panels.isOpen('notes.reference'); // boolean
|
||||
sw.panels.isAvailable('notes.reference'); // boolean
|
||||
sw.panels.list(); // ['notes.reference', 'notes.graph']
|
||||
sw.panels.active(); // ['notes.reference']
|
||||
```
|
||||
|
||||
### 5. Presentation Modes
|
||||
|
||||
#### Floating (v0.10.1)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ← │ Chat │ 🔔 │ 👤 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Host surface (interactive) │
|
||||
│ ┌───────────────────┐ │
|
||||
│ │ ≡ Notes ─ ✕ │ │
|
||||
│ │ │ │
|
||||
│ │ [panel content] │ │
|
||||
│ │ │ │
|
||||
│ │ ◢ │ │
|
||||
│ └───────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Draggable via title bar (pointer + touch events).
|
||||
- Resize via bottom-right handle.
|
||||
- Viewport bounds clamping — panel cannot be dragged fully offscreen
|
||||
(at least 48px of title bar must remain visible).
|
||||
- No backdrop — host surface remains fully interactive.
|
||||
- Z-index above surfaces, below Dialog/Drawer overlays.
|
||||
- On focus or drag-start, panel gets next-z (multiple floating panels
|
||||
stack correctly).
|
||||
- Escape key: configurable — close panel or do nothing (default: close).
|
||||
- Position and size persisted in `sw.storage` keyed by panel ID.
|
||||
|
||||
**Title bar chrome (kernel-provided):**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ ≡ │ {icon} {title} │ ─ ✕ │
|
||||
└────────────────────────────────────┘
|
||||
│ │ │
|
||||
drag handle minimize close
|
||||
```
|
||||
|
||||
- **≡** Drag handle / grip indicator.
|
||||
- **─** Minimize: collapses to a small pill at the bottom of the viewport.
|
||||
Click to restore. Minimized state persisted.
|
||||
- **✕** Close: calls cleanup, removes panel.
|
||||
- Title and icon from manifest.
|
||||
|
||||
#### Docked (v0.10.2)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ← │ Chat │ 🔔 │ 👤 │
|
||||
├──────────────────────────────┬──┬────────────────────────┤
|
||||
│ │▐▐│ 📝 Notes ✕ │
|
||||
│ Host surface │▐▐│ │
|
||||
│ (shrinks to fit) │▐▐│ [panel content] │
|
||||
│ │▐▐│ │
|
||||
│ │▐▐│ │
|
||||
└──────────────────────────────┴──┴────────────────────────┘
|
||||
resize
|
||||
gutter
|
||||
```
|
||||
|
||||
- Docked panels consume space from the host surface. The
|
||||
`#extension-mount` area shrinks via CSS flex or grid.
|
||||
- Resize gutter between host and panel (drag to adjust split).
|
||||
- Three dock positions: right (default), left, bottom.
|
||||
- No z-index concerns — docked panels are in normal flow.
|
||||
- Split ratio persisted in `sw.storage` keyed by panel ID + mode.
|
||||
- Close button removes panel, host surface reclaims full width/height.
|
||||
|
||||
#### Mode Transitions (v0.10.2)
|
||||
|
||||
A floating panel can be dragged to a dock zone (edge highlight on
|
||||
hover). A docked panel's title bar can be dragged away to float.
|
||||
The panel component is never unmounted during a mode transition — only
|
||||
the wrapping container changes.
|
||||
|
||||
The user controls presentation mode. Surfaces can suggest a default
|
||||
mode in `sw.panels.open()`, but the user's last-used mode (persisted)
|
||||
takes precedence.
|
||||
|
||||
### 6. Z-Index Strategy
|
||||
|
||||
Panels slot into the existing stacking context:
|
||||
|
||||
| Layer | Z-Index | Contents |
|
||||
|-------|---------|----------|
|
||||
| Base | 0 | Surface content, docked panels (in flow) |
|
||||
| Floating panels | 100–199 | `sw.panels` floating mode (auto-incrementing) |
|
||||
| Shell overlays | 200–299 | Dropdowns, tooltips, menus |
|
||||
| Drawer | 1000 | `sw.ui.Drawer` |
|
||||
| Dialog | 1000 | `sw.ui.Dialog` + confirm/prompt |
|
||||
| Toast | 1100 | `sw.ui.toast` |
|
||||
| Debug | 9999 | Debug panel |
|
||||
|
||||
Floating panels start at z-index 100. Each panel that receives focus
|
||||
or drag-start gets `max(current panel z-indexes) + 1`, capped at 199.
|
||||
If the cap is hit, all floating panels are renumbered starting from 100
|
||||
(maintaining relative order). This prevents unbounded z-index growth.
|
||||
|
||||
When a Dialog or Drawer opens, floating panels remain visible beneath
|
||||
the overlay — they do not auto-hide. This preserves the "see while
|
||||
doing" property.
|
||||
|
||||
### 7. Dependency Resolution + Loading
|
||||
|
||||
**At install time:** The kernel validates that panel dependencies
|
||||
reference valid `<package>.<panel>` identifiers. Invalid references
|
||||
are warnings (soft deps), not errors.
|
||||
|
||||
**At surface load time:**
|
||||
|
||||
1. Kernel reads the surface's `panels` array from its manifest.
|
||||
2. For each declared panel, check if the providing package is installed
|
||||
and enabled. Build the available panel list.
|
||||
3. Register available panels into `sw.panels._register()` with their
|
||||
manifest metadata (entry path, title, icon, size constraints).
|
||||
4. Panel JS is **not** loaded yet — lazy loading on first
|
||||
`sw.panels.open()`.
|
||||
|
||||
**On `sw.panels.open(panelId)`:**
|
||||
|
||||
1. If panel JS not yet loaded: `import()` the entry module from the
|
||||
package's static asset path
|
||||
(`/api/v1/ext/<package>/static/js/panels/<name>.js`).
|
||||
2. Call `module.mount(el, ctx)` where `el` is the content area of the
|
||||
kernel-provided container (FloatingPanel or DockedPanel component).
|
||||
3. Store the cleanup function returned by `mount()`.
|
||||
4. Emit `panels.opened` event with `{ panelId, mode }`.
|
||||
|
||||
**On `sw.panels.close(panelId)`:**
|
||||
|
||||
1. Call stored cleanup function.
|
||||
2. Remove kernel container from DOM.
|
||||
3. Emit `panels.closed` event with `{ panelId }`.
|
||||
|
||||
### 8. Panel Communication
|
||||
|
||||
No new mechanism. Panels and their host surface share the same
|
||||
`sw.events` bus and `sw` SDK instance. Convention-based event
|
||||
namespacing:
|
||||
|
||||
```js
|
||||
// Host surface requests the notes panel to show a specific note
|
||||
sw.emit('panel.notes.reference.show', { noteId: '...' });
|
||||
|
||||
// Panel listens
|
||||
sw.on('panel.notes.reference.show', (data) => {
|
||||
navigateToNote(data.noteId);
|
||||
});
|
||||
|
||||
// Panel notifies the host that a note was selected
|
||||
sw.emit('panel.notes.reference.selected', { noteId: '...', title: '...' });
|
||||
|
||||
// Host listens
|
||||
sw.on('panel.notes.reference.selected', (data) => {
|
||||
insertNoteLink(data);
|
||||
});
|
||||
```
|
||||
|
||||
The `params` object in `sw.panels.open()` provides initial state:
|
||||
|
||||
```js
|
||||
sw.panels.open('notes.reference', {
|
||||
mode: 'floating',
|
||||
params: { folderId: 'inbox', highlight: noteId }
|
||||
});
|
||||
```
|
||||
|
||||
Params are passed to `mount(el, ctx)` as `ctx.params`. This avoids
|
||||
the need for an event round-trip on first open.
|
||||
|
||||
### 9. CSS
|
||||
|
||||
New file: `src/css/panels.css`
|
||||
|
||||
**Floating panel:**
|
||||
|
||||
```css
|
||||
.sw-panel-floating {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
/* z-index set dynamically by JS */
|
||||
}
|
||||
|
||||
.sw-panel-floating__titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-panel-floating__titlebar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sw-panel-floating__title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sw-panel-floating__actions {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.sw-panel-floating__action {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sw-panel-floating__action:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sw-panel-floating__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sw-panel-floating__resize {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
/* Minimized pill */
|
||||
.sw-panel-pill {
|
||||
position: fixed;
|
||||
bottom: var(--sp-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sw-panel-pill:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
```
|
||||
|
||||
**Docked panel:**
|
||||
|
||||
```css
|
||||
.sw-panel-dock-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-panel-dock-container--bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sw-panel-dock-gutter {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
cursor: col-resize;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.sw-panel-dock-container--bottom .sw-panel-dock-gutter {
|
||||
cursor: row-resize;
|
||||
width: auto;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.sw-panel-docked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.sw-panel-docked__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-panel-docked__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
```
|
||||
|
||||
**Touch targets (mobile):**
|
||||
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.sw-panel-floating__action {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sw-panel-floating__titlebar {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.sw-panel-dock-gutter {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.sw-panel-dock-container--bottom .sw-panel-dock-gutter {
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Backend Changes
|
||||
|
||||
**Minimal.** The kernel backend already serves extension static assets
|
||||
and reads manifests. Changes:
|
||||
|
||||
1. **Manifest parsing** (`server/extensions/manifest.go`): Parse the
|
||||
`panels` field from provider manifests (map of panel key → entry
|
||||
metadata). Parse the `panels` field from consumer manifests (array
|
||||
of panel ID strings). Store in the existing `PackageRecord` struct.
|
||||
|
||||
2. **Surface load endpoint** (`/api/v1/surfaces/:id`): Include resolved
|
||||
panel metadata in the response — for each panel declared by the
|
||||
surface, include the provider's panel manifest entry (title, icon,
|
||||
entry path, size constraints) if the provider is installed and
|
||||
enabled. This lets the frontend `sw.panels._register()` at surface
|
||||
boot without additional API calls.
|
||||
|
||||
3. **Static asset serving**: Already works — panels are served from
|
||||
the same package static path as surface JS
|
||||
(`/api/v1/ext/<package>/static/...`).
|
||||
|
||||
**No new tables.** No new migrations. Panel metadata lives in the
|
||||
existing manifest JSON stored in the packages table.
|
||||
|
||||
### 11. Admin Visibility
|
||||
|
||||
The admin packages page already shows package manifests. With panels:
|
||||
|
||||
- The package detail view shows declared panels with their metadata.
|
||||
- The dependency view shows which surfaces consume which panels.
|
||||
- Panel availability warnings surface when a provider package is
|
||||
disabled but consumers reference its panels.
|
||||
|
||||
This falls out naturally from the existing manifest introspection —
|
||||
no new admin surfaces needed.
|
||||
|
||||
---
|
||||
|
||||
## Event Inventory
|
||||
|
||||
| Event | Emitted by | Payload |
|
||||
|-------|-----------|---------|
|
||||
| `panels.opened` | Kernel (sw.panels) | `{ panelId, mode }` |
|
||||
| `panels.closed` | Kernel (sw.panels) | `{ panelId }` |
|
||||
| `panels.mode_changed` | Kernel (sw.panels) | `{ panelId, from, to }` |
|
||||
| `panels.focused` | Kernel (sw.panels) | `{ panelId }` |
|
||||
| `panel.<id>.*` | Convention (package code) | Package-defined |
|
||||
|
||||
All panel events are `localOnly: true` — they do not broadcast over
|
||||
the WebSocket. Panels are a frontend-only concern.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
Panel state persisted in `sw.storage` (localStorage wrapper):
|
||||
|
||||
| Key | Value | Scope |
|
||||
|-----|-------|-------|
|
||||
| `panel_pos_<panelId>` | `{ x, y, w, h }` | Floating position + size |
|
||||
| `panel_mode_<panelId>` | `'floating' \| 'docked-right' \| ...` | Last-used presentation mode |
|
||||
| `panel_split_<panelId>` | `{ ratio: 0.3 }` | Docked split ratio |
|
||||
| `panel_minimized_<panelId>` | `true` | Minimized state |
|
||||
|
||||
Persisted per-user (sw.storage is already user-scoped). Cleared when
|
||||
the providing package is uninstalled.
|
||||
|
||||
---
|
||||
|
||||
## Version Plan
|
||||
|
||||
| Version | Title | Scope |
|
||||
|---------|-------|-------|
|
||||
| v0.10.0 | Panel Manifest + Lifecycle | Manifest `panels` field (provider + consumer). Backend parsing. `sw.panels` SDK module (register, open, close, toggle, isOpen, isAvailable, list). Lazy JS loading pipeline. No presentation UI yet — panels render into a plain unstyled container for contract validation. |
|
||||
| v0.10.1 | FloatingPanel Primitive | `FloatingPanel` Preact component with drag, resize, minimize, z-index stacking, viewport bounds, position persistence. `panels.css` floating section. Touch support. |
|
||||
| v0.10.2 | Docked Panels + Mode Transitions | `DockedPanel` component. Layout negotiation (flex resize of `#extension-mount`). Resize gutter. Drag-to-dock / drag-to-float transitions. `panels.css` docked section. Split ratio persistence. |
|
||||
| v0.10.3 | Panel Communication Patterns | Event namespace conventions documented. `ctx.params` for initial state. SDK helper: `sw.panels.send(panelId, event, data)` as sugar over `sw.emit('panel.' + panelId + '.' + event, data)`. Panel communication section in extension developer guide. |
|
||||
| v0.10.4 | Reference Panel: Notes | Notes package ships `panels.reference` — searchable note list with quick preview. Chat declares it as a consumer. End-to-end proof: open notes panel in chat, select a note, insert link. Test runner coverage. |
|
||||
|
||||
Each version independently CI-green. v0.10.0 is the foundation that
|
||||
all subsequent versions build on.
|
||||
|
||||
---
|
||||
|
||||
## Changeset Plan — v0.10.0
|
||||
|
||||
| CS | Scope | Description |
|
||||
|----|-------|-------------|
|
||||
| CS-1 | Backend (Go) | Manifest parsing: `panels` field on provider + consumer manifests. `PackageRecord` struct update. Surface load endpoint includes resolved panel metadata. |
|
||||
| CS-2 | Frontend (JS) | `sw.panels` SDK module: `createPanels()`, register, open, close, toggle, isOpen, isAvailable, list, active. Lazy `import()` loader. Unstyled container mount. |
|
||||
| CS-3 | Frontend (JS) | SDK boot integration: wire `sw.panels` into SDK boot sequence. Register panels from `__MANIFEST__` surface data. |
|
||||
| CS-4 | Frontend (JS) | Panel lifecycle events: `panels.opened`, `panels.closed`, `panels.focused`. |
|
||||
| CS-5 | Tests | SDK test runner: panel registration, open/close lifecycle, lazy loading, availability checks, event emission. |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Kernel primitive, not package-level | Z-index coordination, drag/resize consistency, layout negotiation, and position persistence are all kernel concerns. Multiple packages need this. |
|
||||
| Soft dependency (not `depends`) | Chat should work without notes installed. Panel availability is a runtime check, not an install-time requirement. |
|
||||
| Presentation-agnostic panel contract | `mount(el, ctx)` / cleanup is the entire API surface. Panels never know if they are floating or docked. This lets us add new modes without touching panel code. |
|
||||
| Lazy loading | Panel JS is not loaded until first `sw.panels.open()`. Surfaces that declare panels but the user never opens them pay zero cost. |
|
||||
| Event bus for communication | `sw.events` already exists and is the established pattern for cross-package communication. No new mechanism needed. |
|
||||
| No new backend tables | Panel metadata is manifest data stored in the existing packages table JSON. No schema changes. |
|
||||
| Z-index band 100–199 | Above surface content, below shell overlays and Dialog/Drawer. Renumber on cap to prevent unbounded growth. |
|
||||
| User controls mode, surface suggests | The surface can pass `mode: 'docked-right'` as a default, but the user's persisted mode preference wins. Users know their own workflow. |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations (post-v0.10.x)
|
||||
|
||||
- **User-composable layouts.** Power users drag panels from different
|
||||
packages into a persistent workspace layout. Requires a layout
|
||||
persistence model beyond per-panel position.
|
||||
- **Panel marketplace metadata.** Panels become a discoverability
|
||||
feature: "this package provides 2 surfaces and 3 panels."
|
||||
- **Multi-monitor.** Pop a floating panel into a separate browser
|
||||
window via `window.open()` + `SharedWorker` for event bus. Very
|
||||
post-1.0.
|
||||
- **Keyboard shortcuts.** `Ctrl+Shift+N` opens the notes panel.
|
||||
Requires a keybinding registry (not yet a kernel primitive).
|
||||
448
docs/DESIGN-shell-contract.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# DESIGN: Shell Contract — v0.7.0
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Extension surfaces render into `#extension-mount` with no shell chrome.
|
||||
A full audit (v0.7.0 surface audit) found: 3/4 primary surfaces lack a
|
||||
notification bell, 2/4 lack a user menu, 4 different topbar implementations,
|
||||
user menu never updates on package/role changes, toast-and-forget error
|
||||
handling, and empty states that explain nothing.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Shell Topbar — Two-Slot Model
|
||||
|
||||
The kernel injects a topbar for all extension surfaces. Two named slots
|
||||
(left + center) let surfaces customize without replacing the entire bar.
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ← │ [left] │ [center: flex-1] │ 🔔 │ 👤 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **← (home):** Always visible. Navigates to `__BASE__/`. Simple `<a>`.
|
||||
- **Left slot:** Defaults to manifest title. Surfaces override with `setLeft()`.
|
||||
- **Center slot:** Empty by default. Surfaces inject tabs, search, pickers.
|
||||
`flex: 1` — expands to fill available space.
|
||||
- **Bell + User Menu:** Always visible. Kernel-managed.
|
||||
|
||||
**Template change** (`surfaces/extension.html`):
|
||||
|
||||
```html
|
||||
{{define "surface-extension"}}
|
||||
<div id="extension-surface" class="extension-surface"
|
||||
data-surface-id="{{.Surface}}">
|
||||
<div id="shell-topbar" class="sw-topbar sw-topbar--shell"></div>
|
||||
<div id="extension-mount" class="extension-mount" data-ext="{{.Surface}}"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
**SDK API:**
|
||||
|
||||
```js
|
||||
sw.shell.topbar.setLeft(vnode) // Override left slot (default: title)
|
||||
sw.shell.topbar.setSlot(vnode) // Set center slot content
|
||||
sw.shell.topbar.setTitle(str) // Shorthand: setLeft with plain text
|
||||
sw.shell.topbar.hide() // Remove topbar entirely
|
||||
sw.shell.topbar.show() // Restore after hiding
|
||||
```
|
||||
|
||||
### 2. Three Navigation Patterns
|
||||
|
||||
The shell topbar provides the top bar. What happens below it is the
|
||||
surface's business. Three patterns emerge naturally:
|
||||
|
||||
#### Pattern A — Default (simple extensions, Docs)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ← │ Surface Title │ 🔔 │ 👤 │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Content (full width) │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Title only, no tabs, no sidebar. Content gets everything.
|
||||
Zero code required — kernel defaults handle it.
|
||||
|
||||
**Used by:** Docs, Notes, Chat, simple extensions.
|
||||
|
||||
#### Pattern B — Flat Tabs (no sidebar, full width)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ← │ Title │ Tab1 │ Tab2 │ Tab3 │ Tab4 │ │ 🔔 │ 👤 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Content (full width, no sidebar) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Tabs in the topbar center slot. No sidebar — content fills the full
|
||||
viewport width. For surfaces with 3–7 sections that don't have sub-items.
|
||||
More real estate for content than a sidebar layout.
|
||||
|
||||
**Surface code:**
|
||||
|
||||
```js
|
||||
sw.shell.topbar.setSlot(html`
|
||||
<div class="sw-topbar__tabs">
|
||||
${sections.map(s => html`
|
||||
<a class="sw-topbar__tab ${active === s.key ? 'active' : ''}"
|
||||
href=${s.href} onClick=${navigate}>${s.label}</a>
|
||||
`)}
|
||||
</div>
|
||||
`);
|
||||
```
|
||||
|
||||
**Used by:** Team Admin (Members / Connections / Workflows / Settings / Activity),
|
||||
Settings, Schedules.
|
||||
|
||||
#### Pattern C — Category Tabs + Sidebar (two-level)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← │ Title │ People │ Workflows │ System │ Mon │ 🔔 │ 👤 │
|
||||
├──────────┬──────────────────────────────────────────────────┤
|
||||
│ Users │ │
|
||||
│ Teams │ Content │
|
||||
│ Groups │ │
|
||||
│ │ │
|
||||
└──────────┴──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Major categories in the topbar (via center slot). Surface renders its own
|
||||
sidebar inside the content area for sub-navigation within the active
|
||||
category. The shell topbar doesn't know about the sidebar — it's a
|
||||
surface-level div below `#extension-mount`.
|
||||
|
||||
**Surface code:**
|
||||
|
||||
```js
|
||||
// Topbar: major categories
|
||||
sw.shell.topbar.setLeft(html`
|
||||
<img src="${BASE}/favicon.svg" width="18" height="18" style="vertical-align:-3px" />
|
||||
<span style="margin-left:6px">Administration</span>
|
||||
`);
|
||||
sw.shell.topbar.setSlot(html`
|
||||
<div class="sw-topbar__tabs">
|
||||
${categories.map(c => html`
|
||||
<a class="sw-topbar__tab ${activeCat === c.key ? 'active' : ''}"
|
||||
href=${c.href} onClick=${navigate}>
|
||||
<${CatIcon} paths=${c.icon} /> ${c.label}
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Content area: sidebar is surface-owned
|
||||
return html`
|
||||
<div class="admin-body">
|
||||
<div class="admin-nav">
|
||||
${sidebarSections.map(s => html`...`)}
|
||||
</div>
|
||||
<div class="admin-content">
|
||||
<${SectionComponent} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
```
|
||||
|
||||
**Used by:** Admin.
|
||||
|
||||
### 3. Kernel-Provided Tab CSS
|
||||
|
||||
The kernel provides `.sw-topbar__tabs` and `.sw-topbar__tab` CSS so
|
||||
surfaces get consistent tab styling. Not required — surfaces can style
|
||||
their own slot content however they want.
|
||||
|
||||
```css
|
||||
.sw-topbar__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sw-topbar__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-topbar__tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sw-topbar__tab.active {
|
||||
color: var(--text);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Primary Surface Migrations
|
||||
|
||||
#### Settings → Pattern B (flat tabs)
|
||||
|
||||
Currently: custom topbar (back + icon + "Settings"), sidebar nav.
|
||||
After: shell topbar with flat tabs, no sidebar. Content full width.
|
||||
|
||||
Settings has 6 sections (General / Appearance / Profile / Teams /
|
||||
Connections / Notifications) — perfect for flat tabs. The sidebar was
|
||||
thin (~140px) and ate width from the content area for no good reason.
|
||||
|
||||
**Migration:**
|
||||
- Delete `settings-topbar` div and CSS.
|
||||
- Delete sidebar nav. Move section links into `sw.shell.topbar.setSlot()`.
|
||||
- Remove `sb_settings_return` sessionStorage — shell home link handles it.
|
||||
- Content area becomes full-width.
|
||||
- Fix Teams section: add "Open Team Admin →" link, show role, add leave action.
|
||||
|
||||
Extension config sections (`__CONFIG_SECTIONS__`) render as additional
|
||||
tabs after the divider. Same as today, just in the topbar instead of sidebar.
|
||||
|
||||
#### Admin → Pattern C (category tabs + sidebar)
|
||||
|
||||
Currently: custom topbar (favicon + "Administration" + category tabs + UserMenu).
|
||||
After: shell topbar with category tabs in center slot, surface-owned sidebar.
|
||||
|
||||
**Migration:**
|
||||
- Delete custom `admin-topbar` div and CSS.
|
||||
- `sw.shell.topbar.setLeft()` with favicon + "Administration".
|
||||
- `sw.shell.topbar.setSlot()` with category tabs (People / Workflows / System / Monitoring).
|
||||
- Bell and user menu come from the shell — delete the explicit `<${UserMenu}>`.
|
||||
- Admin sidebar and content area unchanged — they're below the topbar.
|
||||
- Delete custom `CatIcon` renderer if category tab icons use standard SVG.
|
||||
- Fix hardcoded `favicon.svg` — left slot can use theme-aware image.
|
||||
|
||||
**Result:** Admin looks identical to today but its topbar is kernel-managed.
|
||||
Bell added for free. User menu reactive for free.
|
||||
|
||||
#### Team Admin → Pattern B (flat tabs)
|
||||
|
||||
Currently: custom topbar (back + "Team Admin: {name}"), sidebar nav.
|
||||
After: shell topbar with flat tabs, no sidebar.
|
||||
|
||||
With Groups removed, Team Admin has 5 sections: Members / Connections /
|
||||
Workflows / Settings / Activity. Perfect for flat tabs.
|
||||
|
||||
**Migration:**
|
||||
- Delete `team-admin-topbar` div and CSS.
|
||||
- `sw.shell.topbar.setTitle('Team Admin: ' + team.name)` after team fetch.
|
||||
- Section tabs into `sw.shell.topbar.setSlot()`.
|
||||
- Delete sidebar nav. Content full-width.
|
||||
- Remove `sb_team_admin_return` sessionStorage.
|
||||
- Remove Groups tab entirely (37-line dead-end).
|
||||
- Fix signoff display: `user_id` → `sw.users.displayName()`.
|
||||
|
||||
#### Docs → Pattern A (default)
|
||||
|
||||
Currently: imports `shell/topbar.js` and renders it explicitly.
|
||||
After: shell topbar auto-renders. No surface code needed.
|
||||
|
||||
**Migration:**
|
||||
- Delete `import { Topbar }` and `<${Topbar}>` render.
|
||||
- Shell topbar provides title + bell + user menu automatically.
|
||||
- Docs sidebar (document list) is in the content area, unaffected.
|
||||
|
||||
### 5. User Menu Reactivity
|
||||
|
||||
**The single most impactful fix.**
|
||||
|
||||
**Backend — new WS events:**
|
||||
|
||||
```go
|
||||
// After package install/uninstall/enable/disable:
|
||||
h.hub.BroadcastToUser(userID, "package.changed", map[string]string{
|
||||
"action": "installed", "id": packageID,
|
||||
})
|
||||
|
||||
// After team role/membership change:
|
||||
h.hub.BroadcastToUser(userID, "auth.changed", map[string]string{
|
||||
"reason": "team_role",
|
||||
})
|
||||
```
|
||||
|
||||
**Frontend** (`user-menu.js`):
|
||||
|
||||
```js
|
||||
useEffect(() => {
|
||||
if (!sw?.api?.surfaces?.list) return;
|
||||
|
||||
function fetchSurfaces() {
|
||||
sw.api.surfaces.list().then(data => {
|
||||
const raw = Array.isArray(data) ? data : data?.data || [];
|
||||
setAllSurfaces(raw);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
fetchSurfaces();
|
||||
|
||||
const off1 = sw.on?.('package.changed', fetchSurfaces);
|
||||
const off2 = sw.on?.('auth.changed', fetchSurfaces);
|
||||
|
||||
return () => {
|
||||
if (typeof off1 === 'function') off1();
|
||||
if (typeof off2 === 'function') off2();
|
||||
};
|
||||
}, [authenticated]);
|
||||
```
|
||||
|
||||
**Event inventory:**
|
||||
|
||||
| Event | Trigger | Payload |
|
||||
|-------|---------|---------|
|
||||
| `package.changed` | Install, uninstall, enable, disable | `{ action, id }` |
|
||||
| `auth.changed` | Team role change, group membership change | `{ reason }` |
|
||||
| `notification.read` | Mark notification read | `{ id }` |
|
||||
| `notification.all_read` | Mark all read | `{}` |
|
||||
|
||||
### 6. Notification Read Broadcast
|
||||
|
||||
**Backend** — after `MarkRead()` / `MarkAllRead()`:
|
||||
|
||||
```go
|
||||
h.hub.BroadcastToUser(userID, "notification.read", map[string]string{"id": id})
|
||||
h.hub.BroadcastToUser(userID, "notification.all_read", nil)
|
||||
```
|
||||
|
||||
**Frontend** — `NotificationBell` listens for `.created`, `.read`, `.all_read`:
|
||||
|
||||
```js
|
||||
const onRead = (e) => {
|
||||
setNotifications(prev => prev.map(n =>
|
||||
n.id === e.id ? { ...n, read_at: new Date().toISOString() } : n
|
||||
));
|
||||
};
|
||||
const onAllRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({
|
||||
...n, read_at: n.read_at || new Date().toISOString()
|
||||
})));
|
||||
};
|
||||
```
|
||||
|
||||
### 7. Shell Announcement Global Dismiss
|
||||
|
||||
On dismiss, write: `localStorage.setItem('armature_dismissed_' + hash(text), '1')`.
|
||||
On mount, check. New announcements (changed text) show again.
|
||||
|
||||
Implementation: inline `<script>` in `base.html` — checks localStorage on
|
||||
DOMContentLoaded. Dismiss button writes the key. Works on every surface
|
||||
including login.
|
||||
|
||||
### 8. Error Handling Pass
|
||||
|
||||
**Pattern** — inline error + retry replaces toast-and-forget:
|
||||
|
||||
```js
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await sw.api.whatever.list();
|
||||
setItems(data || []);
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
// In render:
|
||||
${error && html`
|
||||
<div class="sw-inline-error">
|
||||
<span>${error}</span>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${load}>Retry</button>
|
||||
</div>
|
||||
`}
|
||||
```
|
||||
|
||||
**New CSS class** (`sw-primitives.css`):
|
||||
|
||||
```css
|
||||
.sw-inline-error {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--bg-2); border: 1px solid var(--danger);
|
||||
border-radius: var(--radius); font-size: 13px; color: var(--danger);
|
||||
}
|
||||
```
|
||||
|
||||
**Sections requiring this pass:**
|
||||
|
||||
| Surface | Section | Current | After |
|
||||
|---------|---------|---------|-------|
|
||||
| Admin | Workflows | Toast + empty | Inline error + retry |
|
||||
| Admin | Packages | Toast + empty | Inline error + retry |
|
||||
| Admin | Groups | Toast + empty | Inline error + retry |
|
||||
| Team Admin | Workflows (adopt) | Toast + "No global workflows" | Inline error + retry |
|
||||
| Team Admin | Members | Toast + empty | Inline error + retry |
|
||||
| Settings | General | Console warn | Inline error + retry |
|
||||
| Settings | Teams | Toast + empty | Inline error + retry |
|
||||
| Workflow Demo (pkg) | Main | Silent swallow | Inline error + retry |
|
||||
|
||||
### 9. Empty State Guidance
|
||||
|
||||
Every "No X" empty state gets: one-line explanation, primary action or doc link.
|
||||
|
||||
| Surface | Section | Current | After |
|
||||
|---------|---------|---------|-------|
|
||||
| Admin | Groups | "No groups" | "Groups control access to surfaces and features via permissions." + Create button |
|
||||
| Admin | Workflows | "No workflows" | "Workflows define multi-stage approval processes with team roles and SLA tracking." + Create button |
|
||||
| Admin | Teams | "No teams" | "Teams group users for shared connections, workflows, and access control." + Create button |
|
||||
| Team Admin | Workflows | "No workflows — create one or adopt" | Add: "Adopt copies a global workflow for this team to customize." |
|
||||
| Settings | Notifications | "No notification preferences" | "Preferences appear when notification types are configured by an administrator." |
|
||||
|
||||
## Rebrand Asset Inventory
|
||||
|
||||
### Current → Target
|
||||
|
||||
| File | Current | Target |
|
||||
|------|---------|--------|
|
||||
| `favicon.svg` | Dark icon ✅ | Unchanged |
|
||||
| `favicon-light.svg` | **MISNAMED** (520×80 wordmark) | Square icon, light mode **(NEW)** |
|
||||
| `favicon-32.png` | Dark raster ✅ | Unchanged |
|
||||
| `favicon-256.png` | Dark raster ✅ | Unchanged |
|
||||
| `favicon-light-32.png` | Missing | Light raster **(NEW)** |
|
||||
| `favicon-light-256.png` | Missing | Light raster **(NEW)** |
|
||||
| `favicon.ico` | Dark ✅ | Unchanged |
|
||||
| `wordmark.svg` | Missing | **RENAMED** from current `favicon-light.svg` |
|
||||
| `wordmark-dark.svg` | Missing | Light text #E5E5E5 on transparent **(NEW)** |
|
||||
| `manifest.json` | "Self-hosted AI chat..." | "Self-hosted extension platform..." |
|
||||
|
||||
## Bug Fixes (bundled)
|
||||
|
||||
- **evil-chat:** `finally` cleanup + tighten `409` assertion.
|
||||
- **Workflow demo:** Silent `catch` → inline error + retry.
|
||||
- **Hello dashboard:** Delete `packages/hello-dashboard/`.
|
||||
|
||||
## Changeset Plan
|
||||
|
||||
| CS | Scope | Description |
|
||||
|----|-------|-------------|
|
||||
| CS-1 | Backend (Go) | WS events: `notification.read`, `notification.all_read`, `package.changed`, `auth.changed`. Tests. |
|
||||
| CS-2 | Frontend (JS + HTML) | Shell topbar component, SDK API (`setLeft`/`setSlot`/`setTitle`/`hide`), extension.html template, SDK boot auto-mount. |
|
||||
| CS-3 | Frontend (JS) | User menu reactivity: listen for `package.changed` + `auth.changed`. Notification bell: listen for `.read` + `.all_read`. |
|
||||
| CS-4 | Frontend (JS + HTML) | Surface migrations: Settings → Pattern B, Admin → Pattern C, Team Admin → Pattern B, Docs → Pattern A. Delete custom topbars. |
|
||||
| CS-5 | Frontend (JS + CSS) | Error handling pass + empty state guidance. `sw-inline-error` CSS. All sections from inventory. |
|
||||
| CS-6 | Frontend (JS) | Announcement global dismiss (localStorage). |
|
||||
| CS-7 | Static + docs | Rebrand assets, manifest.json, REBRAND-SPEC.md. |
|
||||
| CS-8 | Frontend (JS) | Bug fixes: evil-chat cleanup, workflow demo error, hello-dashboard deletion. |
|
||||
|
||||
Each changeset independently CI-green.
|
||||
922
docs/DESIGN-sidecar-v014x.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# DESIGN: Sidecar Tier — v0.14.x
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Starlark is the right sandbox for most extension logic: declarative,
|
||||
safe, fast to start, no deployment pipeline. But some workloads cannot
|
||||
run in Starlark:
|
||||
|
||||
- **ML inference** — Python + PyTorch/TF model loading, GPU access.
|
||||
- **Media transcoding** — ffmpeg, ImageMagick, Whisper.
|
||||
- **Language servers** — LSP processes for code-workspace.
|
||||
- **Local git** — clone, diff, merge operations on workspace directories.
|
||||
- **Custom runtimes** — anything needing system libraries, native code,
|
||||
or long-running processes.
|
||||
- **Personal local bridges** — a user's laptop exposing filesystem,
|
||||
terminal, clipboard, or local compute to the Armature instance.
|
||||
|
||||
Today, the `http` module lets Starlark call external APIs (`api.http`
|
||||
permission, domain allowlists, SSRF protection). This covers cloud
|
||||
services (OpenAI, S3, external webhooks) but not **co-located processes**
|
||||
that need access to Armature's data, events, and workspace filesystem,
|
||||
nor **personal processes** on a user's own machine.
|
||||
|
||||
The sidecar tier bridges this gap: out-of-process extensions that
|
||||
connect inward to the kernel, authenticate, register capabilities, and
|
||||
participate in the extension ecosystem as first-class citizens — at both
|
||||
the **instance level** (shared infrastructure) and the **user level**
|
||||
(personal local tools).
|
||||
|
||||
**If done wrong, the consequences are severe:**
|
||||
|
||||
- Wrong auth model → arbitrary processes access kernel APIs.
|
||||
- Wrong lifecycle → resource leaks, zombie processes, orphaned
|
||||
registrations.
|
||||
- Wrong API surface → every sidecar extension inherits the debt.
|
||||
- Wrong isolation → a runaway sidecar takes down the cluster.
|
||||
- Wrong discovery → fragile deployments that break on restart.
|
||||
- Wrong scoping → user sidecars see other users' data.
|
||||
|
||||
This is invisible infrastructure. Users never see it. But every
|
||||
extension that needs native code depends on the contract being right.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Kernel manages sidecar processes.** The kernel does not start, stop,
|
||||
or restart sidecars. That's the deployment layer's job (K8s, Docker
|
||||
Compose, systemd, or a human running a binary). The kernel discovers
|
||||
sidecars that connect to it.
|
||||
- **Sidecar marketplace.** Sidecar packages are installed like any other
|
||||
`.pkg` file. The sidecar binary ships separately (container image,
|
||||
standalone binary, pip package). The manifest declares the sidecar
|
||||
endpoint; the operator deploys it.
|
||||
- **Arbitrary bidirectional streaming.** Sidecars communicate via
|
||||
HTTP/JSON request-response and WebSocket events. No gRPC, no custom
|
||||
binary protocols. KISS.
|
||||
- **Hot code reload.** Sidecar updates require process restart. The
|
||||
kernel detects the reconnection and re-registers capabilities.
|
||||
- **Multi-tenant sidecar isolation.** Instance-level sidecars run at the
|
||||
instance level, not per-user. User-level isolation is handled by
|
||||
user sidecars (v0.14.5), which are scoped to the owning user's
|
||||
identity.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connect-Inward Model
|
||||
|
||||
The defining property: **sidecars connect TO the kernel, not the other
|
||||
way around.** The kernel never reaches outward to discover or contact
|
||||
a sidecar. This eliminates:
|
||||
|
||||
- K8s RBAC for the kernel to discover pods.
|
||||
- Service mesh configuration.
|
||||
- DNS-based service discovery.
|
||||
- Any notion of the kernel "managing" external processes.
|
||||
|
||||
A sidecar is just a process that knows the kernel's address and has
|
||||
credentials to authenticate. It could be a container in the same pod, a
|
||||
separate deployment, a systemd service on the same machine, or a process
|
||||
on a developer's laptop connected via tunnel.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Deployment Layer (K8s / Docker / systemd / manual) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ instance │ │ instance │ │ instance │ │
|
||||
│ │ sidecar: │ │ sidecar: │ │ sidecar: │ │
|
||||
│ │ ml-infer │ │ ffmpeg │ │ git-ops │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ connect inward │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Armature Kernel │ │
|
||||
│ │ ┌─────────────────────────────────────┐ │ │
|
||||
│ │ │ Sidecar Registry (PG) │ │ │
|
||||
│ │ │ ml-infer: instance, 3 caps │ │ │
|
||||
│ │ │ ffmpeg: instance, 2 caps │ │ │
|
||||
│ │ │ git-ops: instance, 4 caps │ │ │
|
||||
│ │ │ jeff-local: user:jeff, 3 caps │ │ │
|
||||
│ │ └─────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────▲─────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────────────┼──────────────────┘
|
||||
│ connect inward
|
||||
┌─────┴──────┐
|
||||
│ user │
|
||||
│ sidecar: │
|
||||
│ jeff-local │
|
||||
│ (laptop) │
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
### Two Scopes
|
||||
|
||||
**Instance sidecars** — shared infrastructure. Deployed by the operator.
|
||||
Any user's requests can invoke their capabilities. An ML inference
|
||||
sidecar, a transcoding service, a git operations worker.
|
||||
|
||||
**User sidecars** — personal processes. Run by an individual user on
|
||||
their own machine. Only the owning user's requests can invoke their
|
||||
capabilities. A local file bridge, a personal Ollama instance, a
|
||||
script that reacts to Armature events.
|
||||
|
||||
Both use the same connect-inward mechanism, same registration flow,
|
||||
same capability contract. The difference is the identity binding and
|
||||
access scoping.
|
||||
|
||||
### Two Authentication Modes
|
||||
|
||||
#### Mode A: Registration Token
|
||||
|
||||
For simple deployments (Docker Compose, single machine, dev/test).
|
||||
|
||||
1. Admin generates an instance sidecar token:
|
||||
`armature sidecar token create --package ml-inference`
|
||||
User generates a personal sidecar token (from settings UI or CLI):
|
||||
`armature sidecar token create --personal`
|
||||
2. Token is a signed JWT with claims: `{package_id, scope, user_id, exp}`.
|
||||
Instance tokens: `scope: "instance"`, `user_id: null`.
|
||||
User tokens: `scope: "user"`, `user_id: "<owner>"`.
|
||||
3. Sidecar starts with the token as env: `ARMATURE_TOKEN=ey...`
|
||||
4. Sidecar calls `POST /api/v1/sidecar/register`.
|
||||
5. Kernel validates JWT, creates registry entry with appropriate scope.
|
||||
|
||||
#### Mode B: mTLS
|
||||
|
||||
For production deployments with certificate infrastructure.
|
||||
|
||||
1. Certificate CN encodes identity:
|
||||
Instance: `sidecar:ml-inference`
|
||||
User: `sidecar-user:jeff:local-bridge`
|
||||
2. Kernel's `MTLSNativeProvider` parses the CN prefix and resolves to
|
||||
the appropriate sidecar identity.
|
||||
|
||||
Both modes produce the same internal identity: a `SidecarIdentity`
|
||||
struct with `{package_id, scope, user_id, node_id, registered_at}`.
|
||||
|
||||
### Sidecar Registry
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sidecar_registry (
|
||||
sidecar_id TEXT PRIMARY KEY,
|
||||
package_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'instance',
|
||||
user_id TEXT,
|
||||
endpoint TEXT NOT NULL,
|
||||
heartbeat TIMESTAMPTZ DEFAULT now(),
|
||||
capabilities JSONB DEFAULT '[]',
|
||||
stats JSONB DEFAULT '{}',
|
||||
registered_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sidecar_pkg ON sidecar_registry (package_id);
|
||||
CREATE INDEX idx_sidecar_user ON sidecar_registry (user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`scope` is `'instance'` or `'user'`. `user_id` is NULL for instance
|
||||
sidecars, set for user sidecars.
|
||||
|
||||
**Lifecycle (mirrors cluster registry):**
|
||||
|
||||
- **Register:** `POST /api/v1/sidecar/register` with capability
|
||||
manifest. Kernel creates/updates registry row.
|
||||
- **Heartbeat:** `POST /api/v1/sidecar/heartbeat` every N seconds.
|
||||
- **Sweep:** Kernel's heartbeat loop sweeps stale sidecar entries
|
||||
alongside node entries. 3× heartbeat interval = stale.
|
||||
- **Self-eviction:** Heartbeat returns 404 → re-register or exit.
|
||||
- **Deregister:** `DELETE /api/v1/sidecar/deregister` on graceful
|
||||
shutdown.
|
||||
|
||||
### Capability Registration
|
||||
|
||||
On registration, the sidecar declares capabilities:
|
||||
|
||||
```json
|
||||
POST /api/v1/sidecar/register
|
||||
{
|
||||
"package_id": "ml-inference",
|
||||
"endpoint": "http://ml-inference:8080",
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "embed",
|
||||
"description": "Generate text embeddings",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"model": { "type": "string", "default": "all-MiniLM-L6-v2" }
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
{
|
||||
"name": "classify",
|
||||
"description": "Zero-shot text classification",
|
||||
"input_schema": { ... },
|
||||
"timeout_seconds": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Sidecar SDK (Starlark)
|
||||
|
||||
New `sidecar` Starlark module. Permission: `sidecar.call`.
|
||||
|
||||
```python
|
||||
# Call an instance sidecar capability
|
||||
result = sidecar.call("ml-inference", "embed", {"text": "Hello"})
|
||||
|
||||
# Check availability
|
||||
available = sidecar.available("ml-inference")
|
||||
|
||||
# List capabilities
|
||||
caps = sidecar.capabilities("ml-inference")
|
||||
```
|
||||
|
||||
User sidecar calls are routed automatically. When a request originates
|
||||
from user Jeff, `sidecar.call("user-bridge", "fs.search", {...})` hits
|
||||
Jeff's personal sidecar. The same call from user Alice would hit Alice's
|
||||
personal `user-bridge` sidecar (or fail if she doesn't have one). The
|
||||
Starlark caller doesn't specify the user — the kernel resolves it from
|
||||
the request context.
|
||||
|
||||
### Sidecar SDK (Frontend)
|
||||
|
||||
```js
|
||||
const result = await sw.sidecar.call('ml-inference', 'embed', {
|
||||
text: 'Hello, world'
|
||||
});
|
||||
const available = await sw.sidecar.available('user-bridge');
|
||||
```
|
||||
|
||||
Frontend calls go through the kernel API. The kernel proxies to the
|
||||
appropriate sidecar based on scope + user identity.
|
||||
|
||||
### Sidecar API Contract (Inbound to Kernel)
|
||||
|
||||
| Endpoint | Purpose | Auth |
|
||||
|----------|---------|------|
|
||||
| `POST /api/v1/sidecar/register` | Register + declare capabilities | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/heartbeat` | Heartbeat + stats | Token or mTLS |
|
||||
| `DELETE /api/v1/sidecar/deregister` | Graceful shutdown | Token or mTLS |
|
||||
| `GET /api/v1/ext/{pkg}/*` | Read extension data (scoped) | Token or mTLS |
|
||||
| `POST /api/v1/ext/{pkg}/*` | Write extension data (scoped) | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/emit` | Emit events | Token or mTLS |
|
||||
| `GET /api/v1/sidecar/subscribe` | WebSocket event subscription | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/lib/{pkg}/{fn}` | Cross-package function call | Token or mTLS |
|
||||
|
||||
**Scoping rules:**
|
||||
|
||||
- Instance sidecars: can access `/api/v1/ext/{own_package}/*` only.
|
||||
- User sidecars: can access `/api/v1/ext/{own_package}/*` scoped to
|
||||
the owning user's data within that package. The sidecar sees the
|
||||
same data the user would see through the UI.
|
||||
- Event emission scoped to the sidecar's package prefix.
|
||||
|
||||
### Sidecar API Contract (Outbound from Kernel)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST {sidecar_endpoint}/api/v1/exec/{capability}` | Execute a capability |
|
||||
| `GET {sidecar_endpoint}/api/v1/health` | Health check |
|
||||
|
||||
The `/exec/{capability}` endpoint receives:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": { ... },
|
||||
"context": {
|
||||
"user_id": "...",
|
||||
"package_id": "...",
|
||||
"request_id": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": { ... },
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
What the kernel enforces at the proxy layer:
|
||||
|
||||
- **Timeout:** Per-capability, from capability declaration.
|
||||
- **Request rate:** Per-sidecar, configurable in admin.
|
||||
- **Response size:** Default 10MB, configurable per-sidecar.
|
||||
- **Concurrent calls:** Default 10, configurable per-sidecar.
|
||||
|
||||
User sidecars have separate (typically lower) defaults:
|
||||
|
||||
- **User sidecar rate limit:** Default 30/min (vs 100/min instance).
|
||||
- **User sidecar concurrency:** Default 3 (vs 10 instance).
|
||||
- **User sidecar response size:** Default 5MB (vs 10MB instance).
|
||||
|
||||
Admin can adjust user sidecar defaults globally. Individual users
|
||||
cannot override admin limits.
|
||||
|
||||
### Manifest Integration
|
||||
|
||||
Instance sidecar manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ml-inference",
|
||||
"type": "full",
|
||||
"tier": "sidecar",
|
||||
"version": "1.0.0",
|
||||
|
||||
"sidecar": {
|
||||
"image": "gobha/armature-ml-inference:latest",
|
||||
"required": true,
|
||||
"health_endpoint": "/api/v1/health",
|
||||
"env_hints": {
|
||||
"ARMATURE_URL": "Kernel URL (auto-populated)",
|
||||
"ARMATURE_TOKEN": "Registration token",
|
||||
"MODEL_PATH": "Path to model files"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
User sidecar manifest (installed as a regular package, sidecar runs
|
||||
on user's machine):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "user-bridge",
|
||||
"type": "full",
|
||||
"tier": "sidecar",
|
||||
"version": "1.0.0",
|
||||
|
||||
"sidecar": {
|
||||
"scope": "user",
|
||||
"required": false,
|
||||
"download_url": "https://github.com/armature/user-bridge/releases",
|
||||
"env_hints": {
|
||||
"ARMATURE_URL": "Your Armature instance URL",
|
||||
"ARMATURE_TOKEN": "Generate from Settings → Sidecars"
|
||||
}
|
||||
},
|
||||
|
||||
"surfaces": ["/user-bridge/settings"]
|
||||
}
|
||||
```
|
||||
|
||||
`sidecar.scope: "user"` tells the kernel this is a user sidecar
|
||||
package. The package installs normally (admin or self-install depending
|
||||
on permissions), but the sidecar binary runs on the user's machine.
|
||||
|
||||
### Tool Meta-Tool Integration
|
||||
|
||||
When `llm-bridge` assembles tools for a completion:
|
||||
|
||||
1. Instance-level actions from `sw.actions.list()` → available to all.
|
||||
2. Instance sidecar capabilities → available to all.
|
||||
3. **Requesting user's personal sidecar capabilities** → available to
|
||||
that user only.
|
||||
|
||||
This means different users get different tool sets. Jeff has
|
||||
`user-bridge` running with `fs.search`, `terminal.exec`, and
|
||||
`clipboard.get`. Alice doesn't. When Jeff asks "Hey Max, find the auth
|
||||
file on my desktop," Max has `user-bridge.fs.search` in his tool set.
|
||||
When Alice asks the same, that tool doesn't exist — Max tells her he
|
||||
can't access her local files.
|
||||
|
||||
The same AI persona, different capabilities per user, zero configuration.
|
||||
|
||||
### Admin UI
|
||||
|
||||
**Instance sidecar management:**
|
||||
- Sidecar health status per registered instance sidecar.
|
||||
- Capabilities list with call counts, error rates, latency.
|
||||
- Token management: generate, revoke, list.
|
||||
- Per-sidecar rate limit and concurrency configuration.
|
||||
|
||||
**User sidecar admin controls:**
|
||||
- Permission: `sidecar.connect_personal` — admin grants to specific
|
||||
users or groups.
|
||||
- Capability allowlist for user sidecars — admin restricts which
|
||||
capabilities user sidecars can declare.
|
||||
- Global user sidecar resource limits (rate, concurrency, response
|
||||
size).
|
||||
- Monitoring: list of all connected user sidecars with owner, endpoint,
|
||||
capabilities, last heartbeat.
|
||||
- Kill switch: admin can revoke any user sidecar token or sweep a
|
||||
specific user's sidecars.
|
||||
|
||||
**User settings (for users with `sidecar.connect_personal`):**
|
||||
- Generate personal sidecar token.
|
||||
- List personal connected sidecars with health status.
|
||||
- Revoke own tokens.
|
||||
- Download link / instructions for user sidecar binaries (from package
|
||||
manifest `download_url`).
|
||||
|
||||
### Monitoring
|
||||
|
||||
Kernel-side metrics:
|
||||
|
||||
- `sidecar_call_total{package, capability, scope, status}` — counter.
|
||||
- `sidecar_call_duration_seconds{package, capability, scope}` — histogram.
|
||||
- `sidecar_heartbeat_age_seconds{package, scope}` — gauge.
|
||||
- `sidecar_active{package, scope}` — gauge.
|
||||
- `sidecar_user_count` — gauge (number of connected user sidecars).
|
||||
|
||||
---
|
||||
|
||||
## User Sidecar Use Cases
|
||||
|
||||
### Local Bridge (Desktop Commander Pattern)
|
||||
|
||||
A thin agent on the user's laptop that exposes local capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "fs.search",
|
||||
"description": "Search local filesystem by filename or content",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"query": { "type": "string" },
|
||||
"path": { "type": "string", "default": "~" },
|
||||
"content_search": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["query"]
|
||||
},
|
||||
"timeout_seconds": 15
|
||||
},
|
||||
{
|
||||
"name": "fs.read",
|
||||
"description": "Read a local file",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"max_bytes": { "type": "integer", "default": 1048576 }
|
||||
},
|
||||
"required": ["path"]
|
||||
},
|
||||
"timeout_seconds": 10
|
||||
},
|
||||
{
|
||||
"name": "terminal.exec",
|
||||
"description": "Execute a shell command",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"command": { "type": "string" },
|
||||
"cwd": { "type": "string", "default": "~" },
|
||||
"timeout": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["command"]
|
||||
},
|
||||
"timeout_seconds": 60
|
||||
},
|
||||
{
|
||||
"name": "clipboard.get",
|
||||
"description": "Read clipboard contents",
|
||||
"input_schema": {},
|
||||
"timeout_seconds": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tool meta-tool flow:**
|
||||
|
||||
```
|
||||
User: "Hey Max, find the database migration script I was
|
||||
working on yesterday and add it to my notes"
|
||||
|
||||
llm-bridge assembles (for this user):
|
||||
Layer 5 tools: [...instance tools..., user-bridge.fs.search,
|
||||
user-bridge.fs.read, notes.create, ...]
|
||||
|
||||
LLM:
|
||||
1. Tool: user-bridge.fs.search({query: "migration", path: "~/code"})
|
||||
2. Result: [{path: "~/code/armature/migrations/015_sidecar.sql", ...}]
|
||||
3. Tool: user-bridge.fs.read({path: "~/code/armature/migrations/015_sidecar.sql"})
|
||||
4. Result: {content: "CREATE TABLE IF NOT EXISTS sidecar_registry..."}
|
||||
5. Tool: notes.create({title: "Migration 015 - Sidecar Registry",
|
||||
body: "```sql\nCREATE TABLE..."})
|
||||
6. Text: "Found your sidecar migration script and saved it to Notes."
|
||||
```
|
||||
|
||||
Three extensions orchestrated: user's local filesystem, instance-level
|
||||
notes, all through the same tool meta-tool pattern.
|
||||
|
||||
### Personal Compute (Local Ollama)
|
||||
|
||||
User runs Ollama on their workstation with a GPU:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "complete",
|
||||
"description": "LLM completion via local Ollama",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"prompt": { "type": "string" },
|
||||
"model": { "type": "string", "default": "llama3" }
|
||||
},
|
||||
"required": ["prompt"]
|
||||
},
|
||||
"timeout_seconds": 120
|
||||
},
|
||||
{
|
||||
"name": "embed",
|
||||
"description": "Local embedding via Ollama",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"model": { "type": "string", "default": "nomic-embed-text" }
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"timeout_seconds": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`llm-bridge` can be configured (per-user setting or persona preference)
|
||||
to route completions through the user's local Ollama instead of the
|
||||
instance-level provider. BYOK taken to the extreme — bring your own
|
||||
hardware.
|
||||
|
||||
### Personal Automation
|
||||
|
||||
A script that reacts to Armature events:
|
||||
|
||||
```python
|
||||
# Personal deploy watcher — connects to Armature, subscribes to
|
||||
# events in the #deploys conversation, runs kubectl locally
|
||||
|
||||
ws = connect(f'{KERNEL_URL}/api/v1/sidecar/subscribe',
|
||||
token=TOKEN)
|
||||
|
||||
for event in ws:
|
||||
if event['type'] == 'chat.message' and 'deploy' in event['content']:
|
||||
result = subprocess.run(['kubectl', 'get', 'pods', '-n', 'prod'],
|
||||
capture_output=True, text=True)
|
||||
# Post result back to chat
|
||||
requests.post(f'{KERNEL_URL}/api/v1/sidecar/lib/chat-core/send',
|
||||
json={'conversation_id': event['conversation_id'],
|
||||
'content': f'```\n{result.stdout}\n```'},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
```
|
||||
|
||||
Personal workflow glue. No instance-level deployment required.
|
||||
|
||||
---
|
||||
|
||||
## RBAC for User Sidecars
|
||||
|
||||
Three-layer permission model:
|
||||
|
||||
### Layer 1: Admin Controls WHO
|
||||
|
||||
New permission: `sidecar.connect_personal`.
|
||||
|
||||
Admin grants to specific users, groups, or roles. Most users don't
|
||||
need it. Developers, power users, AI-heavy workflows — they get the
|
||||
permission.
|
||||
|
||||
Default: not granted. Opt-in only.
|
||||
|
||||
### Layer 2: Admin Controls WHAT
|
||||
|
||||
**Capability allowlist for user sidecars.**
|
||||
|
||||
Admin setting: `SIDECAR_USER_ALLOWED_CAPABILITIES`
|
||||
|
||||
Default: `["fs.search", "fs.read", "clipboard.get"]` — safe read-only
|
||||
operations.
|
||||
|
||||
To enable terminal access: admin adds `"terminal.exec"` to the
|
||||
allowlist. This is a deliberate escalation requiring admin action.
|
||||
|
||||
A user sidecar that attempts to register a capability not on the
|
||||
allowlist gets a clear error: "Capability 'terminal.exec' not permitted
|
||||
for user sidecars. Contact your administrator."
|
||||
|
||||
### Layer 3: User Controls VISIBILITY
|
||||
|
||||
User sidecar capabilities are **private by default** — only the owning
|
||||
user's requests invoke them.
|
||||
|
||||
Optional: user can share a specific capability with their team via the
|
||||
settings UI. Shared capabilities appear in team members' tool sets
|
||||
(with attribution: "via Jeff's local bridge").
|
||||
|
||||
User sidecars are never instance-wide. That's what instance sidecars
|
||||
are for.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Instance Sidecars
|
||||
|
||||
1. **Compromised binary** → can read/write own package data, call
|
||||
exported functions. Mitigation: package-scoped access, audit logging,
|
||||
token revocation.
|
||||
2. **Token theft** → impersonate sidecar. Mitigation: package-scoped
|
||||
tokens, mTLS eliminates theft, revocable.
|
||||
3. **SSRF via sidecar.call** → Mitigation: endpoints validated at
|
||||
registration, no runtime changes.
|
||||
4. **Resource exhaustion** → Mitigation: per-sidecar concurrency and
|
||||
rate limits, timeout enforcement, response size limits.
|
||||
5. **Privilege escalation via lib.require** → Mitigation: calls
|
||||
attributed to sidecar identity, target permission checks apply.
|
||||
|
||||
### User Sidecars (Additional Concerns)
|
||||
|
||||
6. **User deploys malicious sidecar** → registers capabilities that
|
||||
exfiltrate data when called. Mitigation: capability allowlist (admin
|
||||
controls what capabilities can be declared), private-by-default
|
||||
(only the user's own requests trigger it — they're exfiltrating
|
||||
from themselves).
|
||||
|
||||
7. **User shares malicious capability with team** → team members
|
||||
invoke it, sidecar captures their request data. Mitigation: shared
|
||||
capabilities are clearly attributed ("via Jeff's local bridge").
|
||||
Admin can disable capability sharing entirely. Shared capabilities
|
||||
run with the CALLING user's permissions, not the sidecar owner's.
|
||||
|
||||
8. **User sidecar as pivot** → user's laptop compromised, attacker uses
|
||||
connected sidecar to access Armature data. Mitigation: user sidecar
|
||||
can only access data the user could already access through the UI.
|
||||
No privilege escalation. User can revoke own tokens. Admin kill
|
||||
switch.
|
||||
|
||||
9. **Stale user sidecars** → user's laptop goes offline, sidecar
|
||||
becomes stale. Mitigation: same sweep mechanism as instance
|
||||
sidecars. 3× heartbeat interval = swept. User reconnects when
|
||||
laptop comes back online.
|
||||
|
||||
### Principle of Least Privilege
|
||||
|
||||
- Instance sidecar: access own package data only, middleware-enforced.
|
||||
- User sidecar: access own package data scoped to owning user only.
|
||||
- Event emission scoped to package prefix.
|
||||
- Capability allowlist for user sidecars (admin-controlled).
|
||||
- Shared capabilities run with caller's permissions.
|
||||
|
||||
---
|
||||
|
||||
## Version Plan
|
||||
|
||||
### v0.14.0 — Sidecar Registry + Auth
|
||||
|
||||
**Goal:** Sidecars can register, authenticate, and heartbeat.
|
||||
|
||||
- Sidecar registry table with `scope` and `user_id` columns.
|
||||
- Registration endpoint with JWT token validation.
|
||||
- Heartbeat + sweep integration.
|
||||
- Token generation (instance tokens via admin, user tokens via settings).
|
||||
- `SIDECAR_AUTH_MODE` config: `token` or `mtls`.
|
||||
- mTLS auth: `sidecar:` and `sidecar-user:` CN prefixes.
|
||||
- Admin UI: sidecar health on packages page.
|
||||
|
||||
**Instance sidecars only in this version.** User sidecar plumbing
|
||||
exists in the schema but user token generation and RBAC gating land
|
||||
in v0.14.5.
|
||||
|
||||
### v0.14.1 — Capability Registration + Execution
|
||||
|
||||
**Goal:** Sidecars declare capabilities. Starlark extensions call them.
|
||||
|
||||
- Capability manifest in registration payload.
|
||||
- `sidecar` Starlark module: `call()`, `available()`, `capabilities()`.
|
||||
- Kernel proxy: `sidecar.call()` → `POST {endpoint}/exec/{cap}`.
|
||||
- Input validation against declared schema.
|
||||
- Timeout, response size, concurrent call limits.
|
||||
- Error propagation.
|
||||
- `sidecar.call` permission.
|
||||
- Audit logging.
|
||||
|
||||
### v0.14.2 — Kernel API Access + Event Bus
|
||||
|
||||
**Goal:** Sidecars read/write their own data and participate in events.
|
||||
|
||||
- Sidecar middleware: authenticate, scope to own package routes.
|
||||
- `/api/v1/sidecar/lib/{pkg}/{fn}` — cross-package function calls.
|
||||
- `/api/v1/sidecar/emit` — event emission (package-scoped).
|
||||
- `/api/v1/sidecar/subscribe` — WebSocket event subscription.
|
||||
- Frontend `sw.sidecar` SDK module.
|
||||
|
||||
### v0.14.3 — Manifest Integration + Admin Polish
|
||||
|
||||
**Goal:** Sidecar packages are first-class in install/admin flow.
|
||||
|
||||
- `tier: "sidecar"` manifest support.
|
||||
- `sidecar` manifest field (image, required, scope, env_hints,
|
||||
download_url).
|
||||
- Admin UI: deployment instructions from manifest.
|
||||
- Admin UI: capability list with metrics.
|
||||
- Admin UI: per-sidecar rate limit configuration.
|
||||
- Token management UI.
|
||||
- Monitoring metrics.
|
||||
|
||||
### v0.14.4 — Reference Sidecar + Instance Quality Gate
|
||||
|
||||
**Goal:** Ship one real instance sidecar. Prove the contract.
|
||||
|
||||
**`armature-embed`** — minimal Python sidecar running
|
||||
sentence-transformers for local embedding generation. One capability:
|
||||
`embed(text) → float[]`.
|
||||
|
||||
`vector-store` calls `sidecar.call('armature-embed', 'embed', {...})`
|
||||
for local embedding without an external API. Completes the local-first
|
||||
RAG story.
|
||||
|
||||
**Instance sidecar quality gate:**
|
||||
|
||||
- Registration, heartbeat, sweep lifecycle tested.
|
||||
- Token and mTLS auth both functional.
|
||||
- Capability execution with limits enforced.
|
||||
- Package-scoped API access enforced.
|
||||
- Event emission/subscription functional.
|
||||
- `sw.sidecar` frontend module functional.
|
||||
- Reference sidecar runs in Docker and K8s.
|
||||
- Admin UI complete.
|
||||
- Monitoring metrics in Grafana.
|
||||
|
||||
### v0.14.5 — User Sidecars
|
||||
|
||||
**Goal:** Users connect personal processes to Armature.
|
||||
|
||||
- `sidecar.connect_personal` permission (admin-granted).
|
||||
- User token generation in Settings → Sidecars.
|
||||
- Capability allowlist for user sidecars (admin setting:
|
||||
`SIDECAR_USER_ALLOWED_CAPABILITIES`).
|
||||
- User sidecar registration with `scope: "user"`, `user_id` binding.
|
||||
- Execution routing: `sidecar.call()` resolves user sidecars based
|
||||
on request context (calling user gets their own sidecar).
|
||||
- Capability sharing: user can share specific capabilities with team.
|
||||
Shared caps attributed ("via Jeff's local bridge").
|
||||
- Separate resource limits for user sidecars (lower defaults).
|
||||
- User settings UI: connected sidecars, token management, sidecar
|
||||
instructions from package manifests.
|
||||
- Admin UI: user sidecar monitoring, kill switch.
|
||||
- Tool meta-tool integration: `llm-bridge` includes user's personal
|
||||
sidecar capabilities in tool set assembly.
|
||||
|
||||
**Reference user sidecar: `user-bridge`** — thin agent binary with
|
||||
`fs.search`, `fs.read`, `clipboard.get` capabilities. Published as a
|
||||
package with `sidecar.scope: "user"`. Binary as a GitHub release.
|
||||
|
||||
**User sidecar quality gate:**
|
||||
|
||||
- RBAC: user without `sidecar.connect_personal` cannot register.
|
||||
- Capability allowlist enforced.
|
||||
- User A cannot invoke user B's sidecar.
|
||||
- Shared capabilities run with caller's permissions.
|
||||
- Admin can revoke any user sidecar.
|
||||
- Tool meta-tool assembles per-user tool sets correctly.
|
||||
- `user-bridge` reference sidecar runs on macOS, Linux, Windows.
|
||||
|
||||
---
|
||||
|
||||
## Schema Summary
|
||||
|
||||
### New tables
|
||||
|
||||
- `sidecar_registry` — sidecar_id, package_id, scope, user_id,
|
||||
endpoint, heartbeat, capabilities (JSON), stats (JSON),
|
||||
registered_at. (v0.14.0)
|
||||
- `sidecar_tokens` — id, package_id, scope, user_id, token_hash,
|
||||
created_by, expires_at, revoked_at. (v0.14.0)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `SIDECAR_AUTH_MODE` | `token` | `token` or `mtls` |
|
||||
| `SIDECAR_HEARTBEAT_INTERVAL` | `10s` | Expected heartbeat frequency |
|
||||
| `SIDECAR_STALE_THRESHOLD` | `30s` | 3× heartbeat = stale |
|
||||
| `SIDECAR_MAX_RESPONSE_SIZE` | `10485760` (10MB) | Instance default |
|
||||
| `SIDECAR_DEFAULT_CONCURRENCY` | `10` | Instance default |
|
||||
| `SIDECAR_DEFAULT_RATE_LIMIT` | `100/min` | Instance default |
|
||||
| `SIDECAR_USER_MAX_RESPONSE_SIZE` | `5242880` (5MB) | User default |
|
||||
| `SIDECAR_USER_CONCURRENCY` | `3` | User default |
|
||||
| `SIDECAR_USER_RATE_LIMIT` | `30/min` | User default |
|
||||
| `SIDECAR_USER_ALLOWED_CAPABILITIES` | `["fs.search","fs.read","clipboard.get"]` | Admin-controlled allowlist |
|
||||
|
||||
---
|
||||
|
||||
## Sidecar Author Contract
|
||||
|
||||
### Minimum Viable Sidecar (Python)
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import requests, os, threading, time
|
||||
|
||||
app = Flask(__name__)
|
||||
KERNEL = os.environ['ARMATURE_URL']
|
||||
TOKEN = os.environ['ARMATURE_TOKEN']
|
||||
|
||||
def register():
|
||||
requests.post(f'{KERNEL}/api/v1/sidecar/register',
|
||||
json={
|
||||
'package_id': 'my-sidecar',
|
||||
'endpoint': f'http://localhost:8080',
|
||||
'capabilities': [{
|
||||
'name': 'echo',
|
||||
'description': 'Echo input back',
|
||||
'input_schema': {
|
||||
'type': 'object',
|
||||
'properties': {'text': {'type': 'string'}},
|
||||
'required': ['text']
|
||||
},
|
||||
'timeout_seconds': 10
|
||||
}]
|
||||
},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
|
||||
def heartbeat():
|
||||
while True:
|
||||
time.sleep(10)
|
||||
r = requests.post(f'{KERNEL}/api/v1/sidecar/heartbeat',
|
||||
json={'stats': {}},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
if r.status_code == 404:
|
||||
register()
|
||||
|
||||
@app.route('/api/v1/exec/echo', methods=['POST'])
|
||||
def exec_echo():
|
||||
return jsonify({
|
||||
'output': {'text': request.json['input']['text']},
|
||||
'error': None
|
||||
})
|
||||
|
||||
@app.route('/api/v1/health')
|
||||
def health():
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
register()
|
||||
threading.Thread(target=heartbeat, daemon=True).start()
|
||||
app.run(port=8080)
|
||||
```
|
||||
|
||||
Four things: register, heartbeat, `/exec/`, `/health`. That's the
|
||||
contract.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Connect-inward | Eliminates K8s RBAC, service mesh, DNS discovery. Works on any deployment target. |
|
||||
| Two auth modes | Token for simple. mTLS for production. Same internal identity. |
|
||||
| Cluster registry pattern | Proven heartbeat/sweep. No new coordination mechanism. |
|
||||
| HTTP/JSON, no gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||
| Kernel proxies all calls | Auth, rate limits, audit, timeouts at single control point. |
|
||||
| Package-scoped access | Compromised sidecar can't read other packages. |
|
||||
| User sidecars as v0.14.5 | Prove instance contract first (v0.14.0–v0.14.4), then extend to users. Same mechanism, new scope. |
|
||||
| Three-layer user RBAC | Admin controls who (permission), what (capability allowlist), user controls visibility (private/shared). Defense in depth. |
|
||||
| Capability allowlist for user sidecars | Admin decides what user processes can expose. `terminal.exec` is a deliberate escalation. |
|
||||
| Private-by-default user capabilities | User sidecar can't affect other users unless explicitly shared. |
|
||||
| Shared caps run with caller's perms | Prevents privilege escalation through shared capabilities. |
|
||||
| Reference instance sidecar: embedding | Simpler than LLM. Proves contract. Completes local RAG story. |
|
||||
| Reference user sidecar: file bridge | Most compelling user sidecar demo. "Find a file on my laptop and save it to notes" is visceral. |
|
||||
| Lower resource limits for user sidecars | User sidecars are less trusted (running on personal devices). Lower defaults, admin can't be overridden by users. |
|
||||
| Separate from polish (v0.15.x) | Sidecar is security-critical infrastructure. Polish is quality-of-life. Don't mix. |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Streaming responses.** LLM sidecar needs token streaming. SSE or
|
||||
chunked transfer. Deferred — batch covers embedding, classification,
|
||||
transcoding.
|
||||
- **SDK libraries.** Thin wrappers in Python, Go, Node, Rust for
|
||||
registration + heartbeat + parsing. Convenience, not requirement.
|
||||
- **Auto-scaling.** Multiple instances of same sidecar, kernel
|
||||
load-balances. No schema change needed — registry supports multiple
|
||||
rows per package.
|
||||
- **GPU detection.** Kernel capability for manifest negotiation.
|
||||
- **Sidecar-to-sidecar.** Direct calls for performance pipelines.
|
||||
Currently goes through kernel.
|
||||
- **User sidecar desktop app.** Electron/Tauri tray app that wraps the
|
||||
user-bridge agent with a GUI for capability management and connection
|
||||
status. Post-1.0.
|
||||
668
docs/DESIGN-storage-primitives.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# DESIGN — Storage Primitives
|
||||
|
||||
**Version:** v0.8.0
|
||||
**Status:** Draft
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Extensions cannot access blob storage or managed disk paths. The existing
|
||||
`ObjectStore` interface (PVC + S3 backends) is fully implemented but only
|
||||
exposed to the admin status endpoint — no Starlark bridge exists. The `db`
|
||||
module provides structured data storage via extension-scoped tables, but
|
||||
there is no equivalent for binary files, no managed filesystem for tools
|
||||
like `git`, and no mechanism for extensions to declare environment
|
||||
requirements (pgvector, workspace root, S3) that the kernel validates at
|
||||
install time.
|
||||
|
||||
This blocks every future capability that depends on file handling:
|
||||
RAG/vector search, LLM bridge, code workspaces, file upload/sharing,
|
||||
media processing, and document indexing.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the `db` module. Extension-scoped tables (`ext_{pkg}_{table}`)
|
||||
are the structured data primitive and remain unchanged.
|
||||
- Building a KV store. Extensions that need key-value semantics declare a
|
||||
table with `key TEXT, value TEXT` columns — the infrastructure exists.
|
||||
- Multi-tenant file isolation beyond package scoping. Team/user-level
|
||||
file ACLs are extension-layer concerns built on top of these primitives.
|
||||
- Streaming / chunked upload through Starlark. Large file ingestion goes
|
||||
through HTTP routes; the `files` module handles storage after receipt.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three new primitives plus one extension to the existing `db` module.
|
||||
|
||||
### Primitive 1: `files` Starlark Module
|
||||
|
||||
Bridges the existing `ObjectStore` into the sandbox. Follows the same
|
||||
pattern as `db_module.go`: a `Build*Module` factory, permission-gated,
|
||||
package-scoped key namespacing.
|
||||
|
||||
**Permissions:** `files.read`, `files.write`
|
||||
|
||||
**Starlark API:**
|
||||
|
||||
```python
|
||||
# Write a file. content is string (UTF-8) or bytes.
|
||||
# metadata is an optional dict stored alongside (JSON-serialized).
|
||||
files.put(name, content, content_type="application/octet-stream", metadata={})
|
||||
|
||||
# Read a file. Returns dict: {"content": <bytes>, "content_type": "...", "size": N, "metadata": {...}}
|
||||
# Returns None if not found.
|
||||
result = files.get(name)
|
||||
|
||||
# Read metadata only (no content transfer). Returns dict or None.
|
||||
meta = files.meta(name)
|
||||
|
||||
# List files by prefix. Returns list of dicts: [{"name": "...", "size": N, "content_type": "..."}]
|
||||
entries = files.list(prefix="", limit=100)
|
||||
|
||||
# Delete a file. Idempotent — no error if missing.
|
||||
files.delete(name)
|
||||
|
||||
# Delete all files under a prefix. Use with caution.
|
||||
files.delete_prefix(prefix)
|
||||
|
||||
# Check existence without reading.
|
||||
exists = files.exists(name)
|
||||
```
|
||||
|
||||
**Key namespacing:**
|
||||
|
||||
All keys are automatically prefixed with `ext/{packageID}/`. An extension
|
||||
calling `files.put("models/v1.bin", data)` writes to the ObjectStore key
|
||||
`ext/my-extension/models/v1.bin`. Extensions cannot escape their namespace.
|
||||
|
||||
Name validation rejects `..`, absolute paths, and control characters —
|
||||
same sanitization rules as `physicalTable()` in `db_module.go`.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- New file: `sandbox/files_module.go`
|
||||
- `FilesModuleConfig` struct mirrors `DBModuleConfig`:
|
||||
```go
|
||||
type FilesModuleConfig struct {
|
||||
PackageID string
|
||||
CanWrite bool
|
||||
Store storage.ObjectStore
|
||||
}
|
||||
```
|
||||
- Metadata is stored as a companion JSON object at key
|
||||
`ext/{packageID}/_meta/{name}`. This avoids schema changes — the
|
||||
ObjectStore interface is unchanged. The `files` module manages the
|
||||
companion transparently.
|
||||
- Size limit per `files.put()` call: 50MB (configurable via
|
||||
`EXT_FILES_MAX_SIZE`). Enforced in the builtin before calling
|
||||
`Store.Put()`.
|
||||
- `files.get()` returns content as `starlark.Bytes` for binary safety.
|
||||
Starlark's `Bytes` type was added in go.starlark.net v0.0.0-20240725214946
|
||||
and handles non-UTF-8 content correctly.
|
||||
- If `ObjectStore` is nil (storage not configured), the module is not
|
||||
injected — same pattern as `db` module when `r.db == nil`.
|
||||
|
||||
**Runner wiring:**
|
||||
|
||||
```go
|
||||
// In buildModulesWithLibCtx, add cases:
|
||||
case models.ExtPermFilesRead:
|
||||
if filesLevel < 1 { filesLevel = 1 }
|
||||
case models.ExtPermFilesWrite:
|
||||
filesLevel = 2
|
||||
|
||||
// After permission loop:
|
||||
if filesLevel > 0 && r.objectStore != nil {
|
||||
modules["files"] = BuildFilesModule(ctx, FilesModuleConfig{
|
||||
PackageID: packageID,
|
||||
CanWrite: filesLevel == 2,
|
||||
Store: r.objectStore,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Runner gains `SetObjectStore(s storage.ObjectStore)` setter, called from
|
||||
`main.go` after `storage.Init()`.
|
||||
|
||||
---
|
||||
|
||||
### Primitive 2: `workspace` Starlark Module
|
||||
|
||||
Managed disk directories for extensions that need a real filesystem —
|
||||
git repos, compilers, ffmpeg, pandoc, code analysis tools. These tools
|
||||
cannot operate through put/get blob semantics; they need paths.
|
||||
|
||||
**Permission:** `workspace.manage`
|
||||
|
||||
**Starlark API:**
|
||||
|
||||
```python
|
||||
# Create a named workspace directory. Returns the absolute path.
|
||||
# Idempotent — returns existing path if already created.
|
||||
path = workspace.create(name)
|
||||
|
||||
# Get the path for an existing workspace. Returns string or None.
|
||||
path = workspace.path(name)
|
||||
|
||||
# List workspace names for this extension.
|
||||
names = workspace.list()
|
||||
|
||||
# Delete a workspace and all its contents.
|
||||
workspace.delete(name)
|
||||
|
||||
# Disk usage in bytes for a workspace.
|
||||
size = workspace.usage(name)
|
||||
```
|
||||
|
||||
**Directory layout:**
|
||||
|
||||
```
|
||||
{WORKSPACE_ROOT}/
|
||||
{packageID}/
|
||||
{name}/
|
||||
... (extension-managed contents)
|
||||
```
|
||||
|
||||
`WORKSPACE_ROOT` defaults to `/data/workspaces` (configurable via
|
||||
`WORKSPACE_ROOT` env var). The kernel creates the package subdirectory
|
||||
on first `workspace.create()`. Extensions own everything below their
|
||||
directory — the kernel does not inspect contents.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- New file: `sandbox/workspace_module.go`
|
||||
- `WorkspaceModuleConfig`:
|
||||
```go
|
||||
type WorkspaceModuleConfig struct {
|
||||
PackageID string
|
||||
WorkspaceRoot string
|
||||
}
|
||||
```
|
||||
- Name validation: same rules as table names (`^[a-z][a-z0-9_]{0,62}$`).
|
||||
No path separators, no `..`, no spaces.
|
||||
- `workspace.create()` calls `os.MkdirAll` for the scoped path.
|
||||
- `workspace.delete()` calls `os.RemoveAll` — destructive by design.
|
||||
Extensions must handle confirmation in their own UX.
|
||||
- `workspace.usage()` walks the directory tree and sums file sizes.
|
||||
Bounded by a 10-second context timeout to prevent hangs on huge trees.
|
||||
- **Quota enforcement** (optional): `WORKSPACE_QUOTA_MB` env var. When
|
||||
set, `workspace.create()` checks cumulative usage for the package
|
||||
before creating. Returns error if quota exceeded. Default: unlimited.
|
||||
- If `WORKSPACE_ROOT` is empty or not writable, the module is not
|
||||
injected. Extensions that declare `workspace.manage` without the
|
||||
root configured will have their permission granted but the module
|
||||
absent — same degradation pattern as `db` when no DB is available.
|
||||
|
||||
**Runner wiring:**
|
||||
|
||||
```go
|
||||
case models.ExtPermWorkspaceManage:
|
||||
if r.workspaceRoot != "" {
|
||||
modules["workspace"] = BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||
PackageID: packageID,
|
||||
WorkspaceRoot: r.workspaceRoot,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Runner gains `SetWorkspaceRoot(path string)` setter.
|
||||
|
||||
**Security considerations:**
|
||||
|
||||
- The returned path is an absolute filesystem path. Extensions can pass
|
||||
this to `http` module calls (e.g., POST a file to an API) or use it
|
||||
in `db` records as a reference. They cannot execute arbitrary binaries —
|
||||
the Starlark sandbox has no `os.exec`. Execution requires a sidecar
|
||||
tier package or an `api_route` handler that shells out server-side.
|
||||
- For sidecar-tier packages that _can_ execute binaries, the workspace
|
||||
path is the designated scratch space. The kernel ensures the path is
|
||||
within the scoped directory via `filepath.Clean` + prefix check.
|
||||
|
||||
---
|
||||
|
||||
### Primitive 3: Capability Negotiation
|
||||
|
||||
Extensions declare environment requirements in their manifest. The kernel
|
||||
validates these at install time and reports failures with actionable
|
||||
messages. This is what makes progressive enhancement strategies (e.g.,
|
||||
three-tier vector search) work.
|
||||
|
||||
**Manifest field:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "vector-store",
|
||||
"capabilities": {
|
||||
"required": ["files.read", "files.write"],
|
||||
"optional": ["pgvector"]
|
||||
},
|
||||
"permissions": ["db.write", "files.write"],
|
||||
"db_tables": {
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"source_id": "text",
|
||||
"chunk_text": "text",
|
||||
"embedding": "vector(384)"
|
||||
},
|
||||
"indexes": [["source_id"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`capabilities.required` — install fails if any are missing. Admin gets
|
||||
a clear error: "Package 'vector-store' requires capability 'pgvector'
|
||||
which is not available. Run `CREATE EXTENSION vector;` in your PostgreSQL
|
||||
database to enable it."
|
||||
|
||||
`capabilities.optional` — install succeeds regardless. The capability
|
||||
state is queryable at runtime so extensions can degrade gracefully.
|
||||
|
||||
**Runtime query (Starlark):**
|
||||
|
||||
Settings module is the natural home since it's always available:
|
||||
|
||||
```python
|
||||
# Returns True/False for a capability name.
|
||||
has_pgvector = settings.has_capability("pgvector")
|
||||
```
|
||||
|
||||
**Kernel capability registry:**
|
||||
|
||||
A simple function in the `handlers` package that probes the environment:
|
||||
|
||||
```go
|
||||
func DetectCapabilities(db *sql.DB, isPostgres bool, workspaceRoot string, objStore storage.ObjectStore) map[string]bool {
|
||||
caps := make(map[string]bool)
|
||||
|
||||
// pgvector: check pg_extension
|
||||
if isPostgres && db != nil {
|
||||
var exists bool
|
||||
row := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname='vector')")
|
||||
if row.Scan(&exists) == nil && exists {
|
||||
caps["pgvector"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// workspace: check root is writable
|
||||
if workspaceRoot != "" && storage.IsPathWritable(workspaceRoot) {
|
||||
caps["workspace"] = true
|
||||
}
|
||||
|
||||
// object storage: check configured and healthy
|
||||
if objStore != nil {
|
||||
if err := objStore.Healthy(context.Background()); err == nil {
|
||||
caps["object_storage"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// s3: specific backend check
|
||||
if objStore != nil && objStore.Backend() == "s3" {
|
||||
caps["s3"] = true
|
||||
}
|
||||
|
||||
// postgres: dialect check
|
||||
if isPostgres {
|
||||
caps["postgres"] = true
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
```
|
||||
|
||||
Called once at startup, stored on the `Runner` (or a shared config
|
||||
struct). Re-probed on admin request for the capabilities endpoint.
|
||||
|
||||
**Install-time validation:**
|
||||
|
||||
In the package install handler (`handlers/extensions.go`), after
|
||||
`ParseDBTables` and before `SyncManifestPermissions`:
|
||||
|
||||
```go
|
||||
if caps, ok := ParseCapabilities(manifestMap); ok {
|
||||
missing := CheckRequiredCapabilities(caps.Required, detectedCaps)
|
||||
if len(missing) > 0 {
|
||||
// Roll back: delete the just-created package row
|
||||
h.stores.Packages.Delete(c.Request.Context(), pkg.ID)
|
||||
c.JSON(422, gin.H{
|
||||
"error": "missing required capabilities",
|
||||
"missing": missing,
|
||||
"help": capabilityHelpText(missing),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Admin API:**
|
||||
|
||||
```
|
||||
GET /api/v1/admin/capabilities
|
||||
→ {"pgvector": true, "workspace": true, "object_storage": true, "s3": false, "postgres": true}
|
||||
```
|
||||
|
||||
Displayed in the Admin UI alongside storage status. Gives operators
|
||||
visibility into what their deployment supports.
|
||||
|
||||
---
|
||||
|
||||
### Extension to `db` Module: Vector Column Type
|
||||
|
||||
The existing `mapColType()` in `ext_db_schema.go` gains a `vector` type
|
||||
with progressive enhancement across backends.
|
||||
|
||||
**Manifest declaration:**
|
||||
|
||||
```json
|
||||
"db_tables": {
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"embedding": "vector(384)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Column type mapping:**
|
||||
|
||||
| Manifest type | PG + pgvector | PG without pgvector | SQLite |
|
||||
|------------------|-------------------------|---------------------|--------------|
|
||||
| `vector(N)` | `vector(N)` | `JSONB` | `TEXT` |
|
||||
|
||||
Implementation in `mapColType`:
|
||||
|
||||
```go
|
||||
case "vector":
|
||||
// vector or vector(384) — extract dimension if present
|
||||
dim := extractVectorDim(typStr) // returns "384" or ""
|
||||
if isPostgres && hasPgVector {
|
||||
if dim != "" {
|
||||
return fmt.Sprintf("vector(%s)", dim)
|
||||
}
|
||||
return "vector"
|
||||
}
|
||||
if isPostgres {
|
||||
return "JSONB" // store as JSON array, brute-force search
|
||||
}
|
||||
return "TEXT" // SQLite: JSON array as text
|
||||
```
|
||||
|
||||
`hasPgVector` is passed through a new field on a `SchemaConfig` struct
|
||||
(or detected inline — the capability registry result is available at
|
||||
table creation time).
|
||||
|
||||
**New `db` module function — `db.query_similar()`:**
|
||||
|
||||
```python
|
||||
# Find rows with embeddings closest to the query vector.
|
||||
# Returns list of row dicts with _distance appended.
|
||||
results = db.query_similar(
|
||||
table="embeddings",
|
||||
column="embedding",
|
||||
vector=[0.1, 0.2, ...], # query vector (list of floats)
|
||||
limit=10,
|
||||
filters={"source_id": "doc-123"} # optional WHERE clause
|
||||
)
|
||||
```
|
||||
|
||||
**Backend dispatch:**
|
||||
|
||||
- **PG + pgvector:** Uses `ORDER BY embedding <=> $1 LIMIT $2` with
|
||||
native vector distance operator.
|
||||
- **PG without pgvector / SQLite:** Loads candidate rows (respecting
|
||||
`filters`), deserializes JSON arrays, computes cosine similarity in
|
||||
Go, sorts, returns top-N. This is the "it works but slowly" fallback —
|
||||
acceptable for small corpora (<10k rows), documented as such.
|
||||
|
||||
Implementation: new function `dbQuerySimilar()` in `db_module.go`,
|
||||
gated on `db.read` permission (read-only operation). The function
|
||||
checks `cfg.HasPgVector` to choose the fast or slow path.
|
||||
|
||||
`DBModuleConfig` gains:
|
||||
|
||||
```go
|
||||
type DBModuleConfig struct {
|
||||
PackageID string
|
||||
CanWrite bool
|
||||
DB *sql.DB
|
||||
IsPostgres bool
|
||||
HasPgVector bool // NEW: enables native vector ops
|
||||
}
|
||||
```
|
||||
|
||||
Wired from the capability registry at module build time.
|
||||
|
||||
---
|
||||
|
||||
## New Permission Constants
|
||||
|
||||
```go
|
||||
// In models/models_extension_perm.go:
|
||||
const (
|
||||
ExtPermFilesRead = "files.read"
|
||||
ExtPermFilesWrite = "files.write"
|
||||
ExtPermWorkspaceManage = "workspace.manage"
|
||||
)
|
||||
```
|
||||
|
||||
Added to `ValidExtensionPermissions` map.
|
||||
|
||||
---
|
||||
|
||||
## Schema Changes
|
||||
|
||||
**Migration 015 — none required for kernel tables.**
|
||||
|
||||
The `files` module uses the existing `ObjectStore` interface — no new
|
||||
kernel tables. Metadata companions are stored as ObjectStore objects.
|
||||
|
||||
The `workspace` module uses the filesystem — no tables.
|
||||
|
||||
Capabilities are detected at runtime — no tables.
|
||||
|
||||
The `vector` column type is handled by extension DDL generation in
|
||||
`ext_db_schema.go` — no kernel migration.
|
||||
|
||||
The new permission constants are code-only changes.
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `sandbox/files_module.go` | `files` Starlark module |
|
||||
| `sandbox/files_module_test.go` | Tests using mock ObjectStore |
|
||||
| `sandbox/workspace_module.go` | `workspace` Starlark module |
|
||||
| `sandbox/workspace_module_test.go` | Tests using temp directory |
|
||||
| `handlers/capabilities.go` | `DetectCapabilities()`, `ParseCapabilities()`, admin endpoint |
|
||||
| `handlers/capabilities_test.go` | Tests for capability detection and validation |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `models/models_extension_perm.go` | Add `files.read`, `files.write`, `workspace.manage` |
|
||||
| `sandbox/runner.go` | Add `SetObjectStore()`, `SetWorkspaceRoot()`, wire new modules |
|
||||
| `sandbox/db_module.go` | Add `dbQuerySimilar()`, `HasPgVector` field |
|
||||
| `handlers/ext_db_schema.go` | Extend `mapColType()` for `vector(N)`, pass `hasPgVector` |
|
||||
| `handlers/extensions.go` | Add capability validation in install handler |
|
||||
| `sandbox/settings_module.go` | Add `settings.has_capability()` |
|
||||
| `server/main.go` | Call `DetectCapabilities()`, wire to runner |
|
||||
| `docs/PACKAGE-FORMAT.md` | Document `capabilities` manifest field |
|
||||
| `docs/EXTENSION-GUIDE.md` | Document new modules and vector column type |
|
||||
|
||||
---
|
||||
|
||||
## Changeset Plan
|
||||
|
||||
**CS1 — `files` module + permissions**
|
||||
|
||||
New files: `files_module.go`, `files_module_test.go`.
|
||||
Modified: `models_extension_perm.go`, `runner.go`, `main.go`.
|
||||
Scope: ObjectStore bridge, permission constants, runner wiring.
|
||||
CI-green independently — no schema changes, no existing behavior affected.
|
||||
|
||||
**CS2 — `workspace` module**
|
||||
|
||||
New files: `workspace_module.go`, `workspace_module_test.go`.
|
||||
Modified: `models_extension_perm.go`, `runner.go`, `main.go`.
|
||||
Scope: Managed disk directories, quota enforcement.
|
||||
CI-green independently.
|
||||
|
||||
**CS3 — Capability negotiation**
|
||||
|
||||
New files: `capabilities.go`, `capabilities_test.go`.
|
||||
Modified: `extensions.go` (install validation), `settings_module.go`
|
||||
(`has_capability`), `main.go`.
|
||||
Scope: Detection, install-time validation, runtime query, admin endpoint.
|
||||
CI-green independently.
|
||||
|
||||
**CS4 — Vector column type + `db.query_similar()`**
|
||||
|
||||
Modified: `ext_db_schema.go`, `db_module.go`, `db_module_test.go`.
|
||||
Scope: Column type mapping, similarity query with dual-path dispatch.
|
||||
Depends on CS3 (needs `HasPgVector` from capability registry).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
| Env Var | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `WORKSPACE_ROOT` | `/data/workspaces` | Root directory for workspace module |
|
||||
| `WORKSPACE_QUOTA_MB` | `0` (unlimited) | Per-extension disk quota |
|
||||
| `EXT_FILES_MAX_SIZE` | `52428800` (50MB) | Max single file size via `files.put()` |
|
||||
|
||||
---
|
||||
|
||||
## Example: Vector Store Extension
|
||||
|
||||
A `vector-store` library extension consuming all four primitives:
|
||||
|
||||
**manifest.json:**
|
||||
```json
|
||||
{
|
||||
"id": "vector-store",
|
||||
"title": "Vector Store",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.1.0",
|
||||
"permissions": ["db.write", "files.read", "connections.read"],
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["pgvector"]
|
||||
},
|
||||
"db_tables": {
|
||||
"documents": {
|
||||
"columns": {
|
||||
"source": "text",
|
||||
"chunk_text": "text",
|
||||
"page": "int",
|
||||
"metadata": "text"
|
||||
},
|
||||
"indexes": [["source"]]
|
||||
},
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"document_id": "text",
|
||||
"embedding": "vector(384)",
|
||||
"chunk_index": "int"
|
||||
},
|
||||
"indexes": [["document_id"]]
|
||||
}
|
||||
},
|
||||
"exports": ["ingest", "search", "search_clustered"]
|
||||
}
|
||||
```
|
||||
|
||||
**script.star:**
|
||||
```python
|
||||
def ingest(source_name, chunks):
|
||||
"""Store document chunks and generate embeddings."""
|
||||
for i, chunk in enumerate(chunks):
|
||||
row = db.insert("documents", {
|
||||
"source": source_name,
|
||||
"chunk_text": chunk["text"],
|
||||
"page": chunk.get("page", 0),
|
||||
"metadata": json.encode(chunk.get("metadata", {})),
|
||||
})
|
||||
# Embedding generation delegated to caller (llm-bridge)
|
||||
# Caller passes pre-computed vectors
|
||||
if "embedding" in chunk:
|
||||
db.insert("embeddings", {
|
||||
"document_id": row["id"],
|
||||
"embedding": json.encode(chunk["embedding"]),
|
||||
"chunk_index": i,
|
||||
})
|
||||
|
||||
def search(query_vector, limit=10, source_filter=None):
|
||||
"""Semantic similarity search — dispatches to native or brute-force."""
|
||||
filters = {}
|
||||
if source_filter:
|
||||
filters["source"] = source_filter
|
||||
# query_similar handles pgvector vs fallback transparently
|
||||
results = db.query_similar(
|
||||
table="embeddings",
|
||||
column="embedding",
|
||||
vector=query_vector,
|
||||
limit=limit,
|
||||
)
|
||||
# Hydrate with document text
|
||||
enriched = []
|
||||
for r in results:
|
||||
docs = db.query("documents", filters={"id": r["document_id"]}, limit=1)
|
||||
if docs:
|
||||
enriched.append({
|
||||
"text": docs[0]["chunk_text"],
|
||||
"source": docs[0]["source"],
|
||||
"page": docs[0]["page"],
|
||||
"distance": r["_distance"],
|
||||
})
|
||||
return enriched
|
||||
|
||||
def search_clustered(embeddings_list, k):
|
||||
"""K-means clustering for query-free thematic selection.
|
||||
Returns k representative chunks. Clustering runs in the
|
||||
extension — the kernel provides the data, not the algorithm."""
|
||||
# This would be implemented by a consumer extension with
|
||||
# http access to call a clustering API, or by a sidecar
|
||||
# that runs sklearn. The vector-store library provides
|
||||
# the data access pattern; clustering logic lives elsewhere.
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Content-addressed deduplication.** If multiple extensions store the
|
||||
same PDF, the ObjectStore holds duplicate bytes. A SHA256-keyed blob
|
||||
layer with refcounting would deduplicate transparently. Deferred —
|
||||
adds complexity without clear need pre-1.0.
|
||||
|
||||
- **Extension-to-extension file sharing.** The current design isolates
|
||||
file namespaces per extension. A `files.grant(name, target_pkg_id)`
|
||||
primitive could enable controlled sharing. Deferred — composition
|
||||
through API routes is sufficient initially.
|
||||
|
||||
- **Streaming upload for large files.** Starlark `files.put()` buffers
|
||||
in memory. For files >50MB, extensions should use HTTP `api_routes`
|
||||
with Go handlers that stream directly to ObjectStore. The `files`
|
||||
module is for extension-internal storage, not user-facing upload.
|
||||
|
||||
- **Workspace snapshots.** `workspace.snapshot(name)` → creates a
|
||||
tarball in the `files` store. Useful for backup/reproducibility.
|
||||
Deferred — extensions can implement this themselves.
|
||||
|
||||
- **Rate limiting / quota on `db.query_similar()` fallback.** The
|
||||
brute-force path loads all candidate rows into Go memory. For large
|
||||
tables this is dangerous. A row-count guard (e.g., refuse if >50k
|
||||
candidates) with a clear error message pointing to pgvector is the
|
||||
right safety valve.
|
||||
407
docs/DESIGN-surface-runners.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# DESIGN: Surface Runners — v0.7.1–v0.7.3
|
||||
|
||||
## Status: v0.7.1 Shipped (framework + migrations), v0.7.2–v0.7.3 Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Armature has two test tiers today:
|
||||
|
||||
1. **Go unit tests** — test store methods, handlers, middleware, sandbox.
|
||||
Run in CI on every push. Coverage is good for kernel internals.
|
||||
|
||||
2. **ICD/SDK test runners** — browser-based test suites that validate API
|
||||
endpoint contracts and SDK domain methods. Run manually by navigating
|
||||
to `/s/icd-test-runner` or `/s/sdk-test-runner`.
|
||||
|
||||
Neither tier catches the class of bugs discovered during manual testing:
|
||||
|
||||
- **Cross-surface state:** Notification bell state not syncing, announcement
|
||||
dismiss not persisting across surface navigations.
|
||||
- **Package integration:** Workflow demo shows "not installed" because its
|
||||
API call fails silently. The API works (unit tests pass); the surface's
|
||||
integration with the API is broken.
|
||||
- **Test side-effects:** ICD security tests install `evil.surface` with no
|
||||
cleanup — it leaks into the menu.
|
||||
- **Surface lifecycle:** Surfaces fail to load, mount into wrong containers,
|
||||
miss SDK boot, or render without shell chrome.
|
||||
|
||||
These are integration bugs — they live at the boundary between kernel and
|
||||
package, between surface and surface, between API and UI. They require a
|
||||
new test tier.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Three deliverables across three versions:
|
||||
|
||||
| Version | Deliverable | What it catches |
|
||||
|---------|-------------|-----------------|
|
||||
| v0.7.1 | Runner framework (`sw.testing`) | Framework bugs, standardizes existing runners |
|
||||
| v0.7.2 | Package runners + CI gate | Package integration bugs, regressions |
|
||||
| v0.7.3 | Headless E2E (Playwright) | DOM rendering bugs, navigation flows, visual regressions |
|
||||
|
||||
## v0.7.1 — Runner Framework
|
||||
|
||||
### `sw.testing` SDK Module
|
||||
|
||||
New kernel SDK module at `src/js/sw/sdk/testing.js`. Provides structured
|
||||
test authoring, lifecycle hooks, cleanup tracking, and machine-readable
|
||||
results.
|
||||
|
||||
```js
|
||||
// Extension runner registers suites during load
|
||||
sw.testing.suite('notes-crud', async (s) => {
|
||||
let folderId, noteId;
|
||||
|
||||
s.beforeAll(async () => {
|
||||
// Setup: create a test folder
|
||||
const r = await sw.api.post('/api/v1/ext/notes/folders', {
|
||||
name: 'test-' + Date.now()
|
||||
});
|
||||
folderId = r.id;
|
||||
s.track('folder', folderId); // auto-cleanup
|
||||
});
|
||||
|
||||
s.test('create note', async (t) => {
|
||||
const r = await sw.api.post('/api/v1/ext/notes/notes', {
|
||||
title: 'Test Note', folder_id: folderId, content: '# Hello'
|
||||
});
|
||||
t.assert.ok(r.id, 'note has ID');
|
||||
t.assert.eq(r.title, 'Test Note');
|
||||
noteId = r.id;
|
||||
t.track('note', noteId); // auto-cleanup
|
||||
});
|
||||
|
||||
s.test('renderers fire', async (t) => {
|
||||
// Test that mermaid block in note content triggers renderer
|
||||
await sw.api.patch('/api/v1/ext/notes/notes/' + noteId, {
|
||||
content: '```mermaid\ngraph LR; A-->B\n```'
|
||||
});
|
||||
// Renderer integration tested via DOM assertion
|
||||
// (only meaningful in headless E2E — marked as browser-only)
|
||||
t.browserOnly(() => {
|
||||
const el = document.querySelector('.mermaid svg');
|
||||
t.assert.ok(el, 'mermaid rendered to SVG');
|
||||
});
|
||||
});
|
||||
|
||||
s.afterAll(async () => {
|
||||
// s.track() resources auto-cleaned here
|
||||
// Manual cleanup for anything not tracked
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Core API
|
||||
|
||||
```js
|
||||
sw.testing.suite(name, fn) // Register a test suite
|
||||
sw.testing.run(name?) // Run one suite or all
|
||||
sw.testing.results() // Get structured results (JSON)
|
||||
sw.testing.on('complete', fn) // Event when run finishes
|
||||
|
||||
// Inside suite:
|
||||
s.test(name, fn) // Register a test
|
||||
s.beforeAll(fn) // Runs once before all tests
|
||||
s.afterAll(fn) // Runs once after all tests (always, even on failure)
|
||||
s.beforeEach(fn) // Runs before each test
|
||||
s.afterEach(fn) // Runs after each test
|
||||
s.track(type, id) // Register resource for auto-cleanup
|
||||
s.skip(reason) // Skip entire suite
|
||||
|
||||
// Inside test:
|
||||
t.assert.ok(val, msg) // Truthy
|
||||
t.assert.eq(a, b, msg) // Deep equality
|
||||
t.assert.neq(a, b, msg) // Not equal
|
||||
t.assert.gt(a, b, msg) // Greater than
|
||||
t.assert.match(str, re, msg) // Regex match
|
||||
t.assert.throws(fn, msg) // Expects throw
|
||||
t.assert.status(resp, code, msg) // HTTP status check
|
||||
t.track(type, id) // Register resource for auto-cleanup
|
||||
t.warn(msg) // Emit warning (non-fatal)
|
||||
t.browserOnly(fn) // Only runs in headless E2E, skipped in API-only mode
|
||||
t.skip(reason) // Skip this test
|
||||
```
|
||||
|
||||
### `requires` Declarations
|
||||
|
||||
Runner packages declare dependencies in their manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat-runner",
|
||||
"type": "test-runner",
|
||||
"title": "Chat Runner",
|
||||
"requires": ["chat", "chat-core"],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
On load, the framework calls `GET /api/v1/surfaces` (or equivalent) to
|
||||
check which packages are installed. If any `requires` entry is missing:
|
||||
|
||||
- Suite is marked `skipped` with reason: `"Missing required package: chat-core"`
|
||||
- No tests execute — clean skip, not a failure
|
||||
- The runner registry surface shows the skip reason prominently
|
||||
|
||||
This directly solves the "workflow demo shows not-installed" pattern:
|
||||
the runner *knows* what should be installed and reports clearly when it isn't.
|
||||
|
||||
### Auto-Cleanup
|
||||
|
||||
The `track(type, id)` method registers resources for deletion in `afterAll`.
|
||||
Supported resource types and their cleanup endpoints:
|
||||
|
||||
| Type | Cleanup Action |
|
||||
|------|---------------|
|
||||
| `channel` | `DELETE /api/v1/channels/:id` |
|
||||
| `note` | `DELETE /api/v1/ext/notes/notes/:id` |
|
||||
| `folder` | `DELETE /api/v1/ext/notes/folders/:id` |
|
||||
| `workflow` | `DELETE /api/v1/workflows/:id` |
|
||||
| `schedule` | `DELETE /api/v1/schedules/:id` |
|
||||
| `package` | `DELETE /api/v1/admin/packages/:id` |
|
||||
| `user` | `DELETE /api/v1/admin/users/:id` |
|
||||
| `team` | `DELETE /api/v1/admin/teams/:id` |
|
||||
|
||||
Cleanup runs in reverse order (LIFO) in `afterAll`, regardless of
|
||||
test pass/fail. Cleanup failures are reported as warnings, not failures.
|
||||
The framework never swallows cleanup errors silently.
|
||||
|
||||
### Result Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"runner": "notes-runner",
|
||||
"timestamp": "2026-04-01T12:00:00Z",
|
||||
"duration_ms": 1234,
|
||||
"summary": { "total": 5, "passed": 4, "failed": 0, "warned": 1, "skipped": 0 },
|
||||
"suites": [
|
||||
{
|
||||
"name": "notes-crud",
|
||||
"status": "passed",
|
||||
"duration_ms": 890,
|
||||
"tests": [
|
||||
{
|
||||
"name": "create note",
|
||||
"status": "passed",
|
||||
"duration_ms": 120,
|
||||
"warnings": [],
|
||||
"cleanup": { "tracked": 1, "cleaned": 1, "failed": 0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requires": { "met": ["notes"], "missing": [] }
|
||||
}
|
||||
```
|
||||
|
||||
### Warning Tier
|
||||
|
||||
Three result statuses:
|
||||
|
||||
- **`passed`** — assertions all passed, cleanup succeeded
|
||||
- **`failed`** — at least one assertion failed
|
||||
- **`warned`** — assertions passed but something non-fatal happened:
|
||||
- API returned unexpected shape (extra/missing fields) but test doesn't depend on the exact field
|
||||
- Cleanup failed for a tracked resource
|
||||
- Timing exceeded a soft threshold
|
||||
- `t.warn(msg)` called explicitly
|
||||
|
||||
Warnings are **never silent.** They appear in the UI and structured results.
|
||||
The difference from the current `catch (e) { /* ignore */ }` pattern is
|
||||
that warnings are *visible* — a human or CI system can decide whether
|
||||
to investigate.
|
||||
|
||||
### ICD/SDK Runner Migration
|
||||
|
||||
The existing runners use a hand-rolled framework (`T.test()`, `T.assert()`,
|
||||
`T.authFetch()`). Migration preserves all test logic:
|
||||
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| `T.test(tier, group, name, fn)` | `s.test(name, fn)` inside `sw.testing.suite(tier + '/' + group, fn)` |
|
||||
| `T.assert(cond, msg)` | `t.assert.ok(cond, msg)` |
|
||||
| `T.authFetch(token, method, path, body)` | Kept as utility — not an assertion primitive |
|
||||
| `T.apiPost(...)` | Kept as utility |
|
||||
| Result rendering (`ui.js`) | Delegated to runner registry surface |
|
||||
| No cleanup hooks | `s.track()` + `s.afterAll()` |
|
||||
|
||||
The ICD and SDK runners become packages with `"type": "test-runner"` in
|
||||
their manifests. Their existing surfaces (`/s/icd-test-runner`,
|
||||
`/s/sdk-test-runner`) are replaced by the unified runner registry at
|
||||
`/s/test-runners`.
|
||||
|
||||
## v0.7.2 — Package Runners
|
||||
|
||||
### Runner Inventory
|
||||
|
||||
| Runner | `requires` | Key Assertions |
|
||||
|--------|-----------|---------------|
|
||||
| `notes-runner` | `["notes"]` | CRUD, folders, tags, backlinks, search, markdown rendering, SDK integration |
|
||||
| `chat-runner` | `["chat", "chat-core"]` | Channel CRUD, messaging, participant display, renderer blocks in messages |
|
||||
| `schedules-runner` | `["schedules"]` | Schedule CRUD, cron expression, toggle, Starlark exec |
|
||||
| `workflow-runner` | `["content-approval"]` | Install detection, stage progression, form submission, signoff |
|
||||
| `renderer-runner` | `["mermaid-renderer"]` | `sw.renderers.register` contract, post-render hooks, block rendering |
|
||||
|
||||
### Runner Result API
|
||||
|
||||
New kernel endpoints (no package required — kernel-provided):
|
||||
|
||||
```
|
||||
POST /api/v1/test-runners/run → Run all installed runners
|
||||
POST /api/v1/test-runners/run/:id → Run specific runner
|
||||
GET /api/v1/test-runners/results → Last run results (JSON)
|
||||
GET /api/v1/test-runners/results/:id → Last run results for specific runner
|
||||
```
|
||||
|
||||
These endpoints enable CI to trigger and consume runner results via
|
||||
`curl` without browser automation. The v0.7.3 Playwright harness is
|
||||
additive — not required for CI gating.
|
||||
|
||||
**Auth:** Admin-only. Runners create/delete resources — they must run
|
||||
with elevated permissions.
|
||||
|
||||
### CI Integration
|
||||
|
||||
New stage in `.gitea/workflows/ci.yaml`:
|
||||
|
||||
```yaml
|
||||
test-runners:
|
||||
needs: [unit-tests]
|
||||
steps:
|
||||
- name: Boot server
|
||||
run: |
|
||||
docker compose up -d
|
||||
./ci/wait-for-healthy.sh
|
||||
- name: Run surface runners
|
||||
run: |
|
||||
RESULT=$(curl -s -X POST http://localhost:8080/api/v1/test-runners/run \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN")
|
||||
FAILED=$(echo "$RESULT" | jq '.summary.failed')
|
||||
if [ "$FAILED" != "0" ]; then
|
||||
echo "$RESULT" | jq '.suites[] | select(.status == "failed")'
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Runs in both PG and SQLite pipelines. Server boots with `BUNDLED_PACKAGES=*`
|
||||
so all packages and their runners are installed.
|
||||
|
||||
## v0.7.3 — Headless E2E
|
||||
|
||||
### Playwright Harness
|
||||
|
||||
`ci/e2e-surface-test.sh`:
|
||||
|
||||
1. `docker compose up -d` (server + DB)
|
||||
2. `npx playwright install chromium` (CI caches this)
|
||||
3. Run `ci/e2e-surfaces.spec.ts`
|
||||
4. Collect screenshots on failure
|
||||
5. `docker compose down`
|
||||
|
||||
### Surface Navigation Smoke Test
|
||||
|
||||
```ts
|
||||
test('all surfaces reachable', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/');
|
||||
await page.fill('#username', 'admin');
|
||||
await page.fill('#password', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Navigate through every installed surface
|
||||
const surfaces = ['notes', 'chat', 'admin', 'settings', 'docs'];
|
||||
for (const s of surfaces) {
|
||||
await page.goto(`/s/${s}`);
|
||||
// Assert: page loaded, no uncaught JS errors
|
||||
await expect(page.locator('.sw-topbar')).toBeVisible();
|
||||
// Assert: home link works
|
||||
await page.click('.sw-topbar__home');
|
||||
await expect(page).toHaveURL('/');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This is the automated version of "hello dashboard has no way out" — if
|
||||
any surface fails to render a topbar or its home link doesn't work, CI
|
||||
catches it.
|
||||
|
||||
### Screenshot on Failure
|
||||
|
||||
```ts
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status !== 'passed') {
|
||||
await page.screenshot({
|
||||
path: `ci/artifacts/failure-${testInfo.title}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
const logs = await page.evaluate(() =>
|
||||
(window.__consoleErrors || []).join('\n')
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`ci/artifacts/console-${testInfo.title}.log`, logs
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Artifacts saved to CI workspace. On failure, the developer gets a
|
||||
screenshot + console log dump without needing to reproduce locally.
|
||||
|
||||
### Visual Regression (Optional)
|
||||
|
||||
Not a CI gate in v0.7.3 — produces a diff report for human review:
|
||||
|
||||
```ts
|
||||
test('visual baseline - notes', async ({ page }) => {
|
||||
await page.goto('/s/notes');
|
||||
await expect(page.locator('.sw-topbar')).toBeVisible();
|
||||
await expect(page).toHaveScreenshot('notes.png', {
|
||||
maxDiffPixelRatio: 0.01
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Playwright stores baseline screenshots in `ci/visual-baselines/`.
|
||||
`toHaveScreenshot` compares against baseline and produces a diff image
|
||||
on mismatch. Foundation for future visual regression gating.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
v0.7.0 Shell Contract ← prerequisite: surfaces need topbar before
|
||||
│ runners can assert on it
|
||||
↓
|
||||
v0.7.1 Runner Framework ← standardize test authoring
|
||||
│
|
||||
↓
|
||||
v0.7.2 Package Runners + CI ← write the actual tests, wire into CI
|
||||
│
|
||||
↓
|
||||
v0.7.3 Headless E2E ← automate browser-based runner execution
|
||||
```
|
||||
|
||||
Each version is independently shippable. v0.7.2's API-based CI gate
|
||||
works without v0.7.3's Playwright. v0.7.3 adds coverage for DOM-level
|
||||
bugs that API-only runners can't catch.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Runner package type.** Should `"type": "test-runner"` be a new
|
||||
manifest type, or should runners be `"type": "surface"` with a
|
||||
`"tags": ["test-runner"]` convention? New type is cleaner but requires
|
||||
a `ValidateManifest()` update.
|
||||
|
||||
2. **Runner discovery.** The registry surface needs to find all installed
|
||||
runners. Options: (a) scan installed packages for `type: "test-runner"`,
|
||||
(b) runners register themselves via `sw.testing.register()` during SDK
|
||||
boot. Option (a) is declarative and doesn't require runner JS to load
|
||||
before discovery.
|
||||
|
||||
3. **Parallel vs sequential.** Should runners execute in parallel?
|
||||
Probably not initially — shared DB state means test isolation is hard.
|
||||
Sequential is safer. Parallel can be a future optimization.
|
||||
|
||||
4. **SQLite limitations.** Some runners (cluster, multi-node) are
|
||||
PG-only. The `requires` mechanism should support
|
||||
`"requires_db": "postgres"` for these cases, or runners should
|
||||
self-skip when `sw.config.db_driver === 'sqlite'`.
|
||||
97
docs/DESIGN-vector-column.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Design: Vector Column Type (v0.8.3)
|
||||
|
||||
## Problem
|
||||
|
||||
Extensions building semantic search, RAG, or recommendation features need to
|
||||
store and query high-dimensional vectors (embeddings). Without kernel-level
|
||||
support, each extension would need to reinvent storage, serialization, and
|
||||
similarity search — duplicating effort and missing the pgvector optimization
|
||||
path.
|
||||
|
||||
## Design
|
||||
|
||||
### Three-tier progressive enhancement
|
||||
|
||||
| Backend | Column DDL | Storage | Search |
|
||||
|---------|-----------|---------|--------|
|
||||
| Postgres + pgvector | `vector(N)` + HNSW index | Native vector type | `<=>` operator (index-backed) |
|
||||
| Postgres (no pgvector) | `JSONB` | JSON array | Go-side cosine distance |
|
||||
| SQLite | `TEXT` | JSON string | Go-side cosine distance |
|
||||
|
||||
Extensions declare `"vector(N)"` in their manifest `db_tables` block.
|
||||
The kernel maps this to the appropriate SQL type at install time based on
|
||||
detected capabilities.
|
||||
|
||||
### Manifest example
|
||||
|
||||
```json
|
||||
{
|
||||
"db_tables": {
|
||||
"documents": {
|
||||
"columns": {
|
||||
"title": "text",
|
||||
"embedding": "vector(384)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"optional": ["pgvector"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API surface
|
||||
|
||||
```python
|
||||
# Insert (vector as list)
|
||||
db.insert("documents", {"title": "hello", "embedding": [0.1, 0.2, ...]})
|
||||
|
||||
# Similarity search
|
||||
rows = db.query_similar(
|
||||
"documents", "embedding",
|
||||
vector=[0.1, 0.2, ...],
|
||||
limit=10,
|
||||
filters={"active": True},
|
||||
metric="cosine"
|
||||
)
|
||||
# → [{..., "_distance": 0.023}, ...]
|
||||
```
|
||||
|
||||
### Dispatch paths
|
||||
|
||||
**pgvector path** — SQL-side computation with index:
|
||||
```sql
|
||||
SELECT *, (embedding <=> $1::vector) AS _distance
|
||||
FROM ext_pkg_documents
|
||||
WHERE ... ORDER BY embedding <=> $1::vector LIMIT $2
|
||||
```
|
||||
|
||||
**Fallback path** — Go-side computation:
|
||||
1. `SELECT * FROM table WHERE filters LIMIT 1000`
|
||||
2. Parse each row's vector column from JSON
|
||||
3. Compute cosine distance in Go
|
||||
4. Sort by distance, return top N with `_distance` injected
|
||||
|
||||
### Dimension validation
|
||||
|
||||
- Manifest: 1 ≤ N ≤ 4096 (validated by `parseVectorDim`)
|
||||
- Insert-time: no dimension validation (store whatever list is given)
|
||||
- Query-time: dimension mismatches produce distance = 1.0 (treated as unrelated)
|
||||
|
||||
### Performance characteristics
|
||||
|
||||
| Path | 1K rows | 10K rows | 100K rows |
|
||||
|------|---------|----------|-----------|
|
||||
| pgvector (HNSW) | <1ms | <5ms | <10ms |
|
||||
| Fallback (Go) | <10ms | ~100ms | Not recommended |
|
||||
|
||||
Fallback caps at 1000 rows fetched. Extensions needing large-scale similarity
|
||||
search should declare `capabilities.optional: ["pgvector"]` and degrade
|
||||
gracefully.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only cosine distance metric (v0.8.3). L2 / inner product can be added later.
|
||||
- No dimension validation at insert time.
|
||||
- Fallback path fetches at most 1000 rows — not suitable for large datasets.
|
||||
- HNSW index is only created for pgvector backends.
|
||||
126
docs/DESIGN-workflow-redesign.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# DESIGN — Workflow System Redesign (0.9.x)
|
||||
|
||||
**Version:** v0.9.0
|
||||
**Status:** Draft
|
||||
**Author:** Jeff / Claude session 2026-04-03
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The workflow system spans ~7,600 lines across 25+ files and was built
|
||||
incrementally from v0.3.x through v0.7.10. It works, but several
|
||||
concepts are redundant, primitives are buried, and the Starlark module
|
||||
is read-only. Before shipping reference extensions (v0.10.x) that
|
||||
build on workflows, the system needs cleanup and promotion of reusable
|
||||
primitives.
|
||||
|
||||
## Audit Summary
|
||||
|
||||
Full audit in `/config/Downloads/AUDIT-workflow-0.9.md`. Classification:
|
||||
|
||||
### KEEP — Core primitives that survive
|
||||
|
||||
| Component | File(s) | Lines | Rationale |
|
||||
|-----------|---------|-------|-----------|
|
||||
| Engine core | `workflow/engine.go` | ~400 | Stateless state machine — Start, Advance, Cancel, version-pinned execution |
|
||||
| Conditional routing | `workflow/routing.go` | ~500 | Pure functions, 8 operators, well-tested (497 lines of tests) |
|
||||
| Automated processing | `workflow/automated.go` | ~300 | Starlark hook execution, cycle guard (max 10) |
|
||||
| Scanner | `workflow/scanner.go` | ~200 | Background SLA + staleness checks, 5-minute interval |
|
||||
| Signoff system | engine.go + handlers | ~400 | Multi-party approve/reject with quorum, role-gated |
|
||||
| Assignment queue | handlers | ~300 | Claim/unclaim/complete/cancel lifecycle |
|
||||
| Version snapshots | store + models | ~200 | Immutable snapshots, version-pinned instances |
|
||||
| Typed form system | `models/workflow.go` | ~300 | 8 field types, fieldsets, conditional visibility |
|
||||
| Store interface | `store/workflow_iface.go` | 61 methods | Complete lifecycle coverage |
|
||||
|
||||
### OBE — Superseded by new design
|
||||
|
||||
| Concept | Superseded by |
|
||||
|---------|---------------|
|
||||
| Per-stage `audience` field | Multi-page packages with mixed access declarations |
|
||||
| `entry_mode` (public_link/team_only) | Package-level `scope: adoptable` + per-page access |
|
||||
| `stage_mode` proliferation (4 values) | Collapse to 3: form / delegated / automated |
|
||||
| `stage_type` (simple/dynamic/automated) | Redundant with `starlark_hook` presence check |
|
||||
| `AdoptTeamWorkflow` clone | Package adoption model |
|
||||
| `ExportWorkflowPackage` endpoint | Standard package export |
|
||||
|
||||
### PROMOTE — Buried features to expose as kernel primitives
|
||||
|
||||
1. **Roles → kernel primitive**: `team_user_roles` table, manifest `requires_roles`,
|
||||
team admin UI, kernel middleware, Starlark SDK
|
||||
2. **Typed forms → SDK primitive**: Move from workflow models to `forms` package,
|
||||
FE SDK `sw.forms.render()` / `sw.forms.validate()`
|
||||
3. **Conditional routing → SDK primitive**: Starlark `routing.evaluate(rules, data)`
|
||||
4. **Workflow Starlark module → full read/write**: `workflow.start()`, `.advance()`,
|
||||
`.cancel()`, `.submit_signoff()`
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Starlark Converter Duplication
|
||||
|
||||
Three files contain nearly identical Go↔Starlark conversion functions:
|
||||
|
||||
| File | Functions |
|
||||
|------|-----------|
|
||||
| `workflow/automated.go` | `goToStarlark`, `starlarkDictToMap`, `starlarkToGo` |
|
||||
| `handlers/workflow_hooks.go` | `starlarkDictToMap`, `starlarkToGo`, `jsonToStarlark` |
|
||||
| `sandbox/workflow_module.go` | `goValToStarlark` |
|
||||
|
||||
**Fix:** Consolidate into `sandbox/convert.go`.
|
||||
|
||||
### Snapshot Format Inconsistency
|
||||
|
||||
Two formats exist (wrapped `{stages, workflow}` vs legacy flat array).
|
||||
Three copies of the parser across engine, instance handlers, and
|
||||
assignment handlers.
|
||||
|
||||
**Fix:** Consolidate into one exported function. Standardize on wrapped format.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
| Version | Feature | Dependencies |
|
||||
|---------|---------|-------------|
|
||||
| **v0.9.0** | Starlark converter consolidation + snapshot format cleanup | None |
|
||||
| **v0.9.1** | `team_user_roles` table + management API + admin UI | None |
|
||||
| **v0.9.2** | `scope: adoptable` manifest field + adoption flow with role auto-populate | v0.9.1 |
|
||||
| **v0.9.3** | Promote typed forms to SDK primitive (`sw.forms`) | None |
|
||||
| **v0.9.4** | Deprecate `stage_type`, collapse `stage_mode` to 3 values | None |
|
||||
| **v0.9.5** | Full read/write workflow Starlark module | v0.9.0 |
|
||||
| **v0.9.6** | Conditional routing as SDK primitive | v0.9.5 |
|
||||
| **v0.9.7** | Multi-surface manifest + kernel route resolution | None |
|
||||
| **v0.9.8** | Wire roles into surface access (`access: role:X`) + kernel middleware | v0.9.1, v0.9.7 |
|
||||
|
||||
Each step is CI-green independently. The first four are the high-value items.
|
||||
|
||||
---
|
||||
|
||||
## Files Affected (by component)
|
||||
|
||||
### Converter Consolidation (v0.9.0)
|
||||
- New: `sandbox/convert.go`
|
||||
- Modified: `workflow/automated.go`, `handlers/workflow_hooks.go`, `sandbox/workflow_module.go`
|
||||
|
||||
### Team Roles (v0.9.1)
|
||||
- New: migration (PG + SQLite), `store/team_roles.go`, `handlers/team_roles.go`
|
||||
- Modified: manifest validation, adoption flow, team admin UI
|
||||
|
||||
### Typed Forms (v0.9.3)
|
||||
- New: `forms/` package (extracted from `models/workflow.go`)
|
||||
- New: `sw.forms` SDK module
|
||||
- Modified: workflow engine to import from `forms/` instead of inline
|
||||
|
||||
### Workflow Starlark Module (v0.9.5)
|
||||
- Modified: `sandbox/workflow_module.go` — add start/advance/cancel/signoff
|
||||
- Modified: `workflow/engine.go` — extract interface to break circular import
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Rewriting the workflow engine. The state machine is correct and stays.
|
||||
- Adding a visual workflow builder. That's an extension concern, not kernel.
|
||||
- Multi-tenancy changes. Team scoping works as designed.
|
||||
@@ -3,11 +3,11 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/armature/armature:latest
|
||||
docker pull gobha/armature:latest
|
||||
docker run -p 8080:80 \
|
||||
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
||||
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
|
||||
# Install ALL packages (everything in the image)
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES="*" \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
|
||||
# Install specific packages only
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
||||
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e SKIP_BUNDLED_PACKAGES=true \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
### Custom Bundle Directory
|
||||
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
||||
-v /host/packages:/custom/packages \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
## Builder Image
|
||||
@@ -102,7 +102,7 @@ docker run -p 8080:80 \
|
||||
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/armature/builder:latest
|
||||
docker pull gobha/armature-builder:latest
|
||||
```
|
||||
|
||||
### What It Caches
|
||||
@@ -117,7 +117,7 @@ docker pull ghcr.io/armature/builder:latest
|
||||
Reference the builder image as a base stage in your Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
FROM ghcr.io/armature/builder:latest AS builder
|
||||
FROM gobha/armature-builder:latest AS builder
|
||||
WORKDIR /app
|
||||
COPY server/ .
|
||||
RUN go build -ldflags="-s -w" -o /bin/armature .
|
||||
@@ -148,7 +148,7 @@ To exclude specific packages from the bundle, either:
|
||||
### Forking for Custom Builds
|
||||
|
||||
```bash
|
||||
git clone https://github.com/armature/armature.git
|
||||
git clone https://github.com/gobha/armature.git
|
||||
cd armature
|
||||
|
||||
# Add/modify packages
|
||||
@@ -189,13 +189,13 @@ docker run -p 8080:80 \
|
||||
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
|
||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
|
||||
# SQLite (evaluation only)
|
||||
docker run -p 8080:80 \
|
||||
-e DB_DRIVER=sqlite \
|
||||
-v armature-data:/data \
|
||||
ghcr.io/armature/armature:latest
|
||||
gobha/armature:latest
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
196
docs/EXTENSION-CSS.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Extension CSS Contract
|
||||
|
||||
> **Version**: v0.6.13 — Responsive & Spacing
|
||||
|
||||
This document defines the CSS contract between the Armature kernel and extension
|
||||
packages. Extensions **must** follow these rules; the kernel guarantees the listed
|
||||
classes and variables are stable public API.
|
||||
|
||||
---
|
||||
|
||||
## Naming Rule
|
||||
|
||||
All class selectors in extension CSS (`packages/{slug}/css/main.css`) must start
|
||||
with `.ext-{slug}-`. The `{slug}` is the package directory name.
|
||||
|
||||
```css
|
||||
/* Good */
|
||||
.ext-my-app-sidebar { ... }
|
||||
.ext-my-app-card { ... }
|
||||
|
||||
/* Bad — will be rejected by the linter */
|
||||
.sidebar { ... }
|
||||
.my-sidebar { ... }
|
||||
```
|
||||
|
||||
**Compound selectors**: Descendant classes scoped under your `.ext-{slug}` root
|
||||
are allowed to reference kernel classes or state modifiers:
|
||||
|
||||
```css
|
||||
/* Allowed — kernel class scoped under extension namespace */
|
||||
.ext-my-app .sw-btn { margin-top: 8px; }
|
||||
|
||||
/* Allowed — state modifier on an extension element */
|
||||
.ext-my-app-item.active { ... }
|
||||
```
|
||||
|
||||
Run `bash scripts/lint-package-css.sh` to validate. The linter checks that the
|
||||
**first** class selector in every rule starts with `.ext-{slug}`.
|
||||
|
||||
---
|
||||
|
||||
## Stable Kernel Classes
|
||||
|
||||
Extensions may reference these classes in compound selectors. They are part of
|
||||
the public API and will not change without a major version bump.
|
||||
|
||||
### Components (from `sw-primitives.css`)
|
||||
|
||||
| Class pattern | Component |
|
||||
|---------------|-----------|
|
||||
| `.sw-btn`, `.sw-btn--{variant}`, `.sw-btn--{size}` | Buttons |
|
||||
| `.sw-input` | Text inputs |
|
||||
| `.sw-field`, `.sw-field__label`, `.sw-field__hint` | Form fields |
|
||||
| `.sw-dialog`, `.sw-dialog__header`, `.sw-dialog__body`, `.sw-dialog__footer` | Dialogs |
|
||||
| `.sw-toast`, `.sw-toast-container` | Toast notifications |
|
||||
| `.sw-menu`, `.sw-menu-item` | Context menus |
|
||||
| `.sw-tabs`, `.sw-tab-btn` | Tab strips |
|
||||
| `.sw-dropdown` | Custom dropdowns |
|
||||
| `.sw-spinner` | Loading spinners |
|
||||
| `.sw-avatar` | User avatars |
|
||||
| `.sw-drawer` | Slide-out drawers |
|
||||
| `.sw-banner` | Banner bars |
|
||||
| `.sw-tooltip` | Tooltips |
|
||||
|
||||
### Extension Mount
|
||||
|
||||
The extension surface container has a `data-ext` attribute set to the package
|
||||
slug. Use this for scoping if needed:
|
||||
|
||||
```css
|
||||
[data-ext="my-app"] .ext-my-app-sidebar { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stable CSS Variables
|
||||
|
||||
All variables from `variables.css` are public API. Extensions should use these
|
||||
instead of hardcoded colors to respect the user's theme.
|
||||
|
||||
### Colors
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `--bg` | Page background |
|
||||
| `--bg-secondary` | Secondary/darker background |
|
||||
| `--bg-elevated` | Elevated surface background |
|
||||
| `--bg-raised` | Raised card background |
|
||||
| `--bg-surface` | Surface-level background |
|
||||
| `--bg-hover` | Hover state background |
|
||||
| `--bg-active` | Active/pressed state background |
|
||||
| `--bg-code` | Code block background |
|
||||
| `--text` | Primary text color |
|
||||
| `--text-2` | Secondary text color |
|
||||
| `--text-3` | Tertiary/muted text color |
|
||||
| `--text-on-color` | Text on colored backgrounds |
|
||||
| `--accent` | Primary accent color |
|
||||
| `--accent-dim` | Dimmed accent for backgrounds |
|
||||
| `--accent-hover` | Accent hover state |
|
||||
| `--accent-light` | Light accent variant |
|
||||
| `--border` | Default border color |
|
||||
| `--border-light` | Light border variant |
|
||||
| `--border-elevated` | Border for elevated surfaces |
|
||||
| `--danger` | Error/destructive color |
|
||||
| `--danger-dim` | Dimmed danger background |
|
||||
| `--danger-light` | Light danger variant |
|
||||
| `--success` | Success/positive color |
|
||||
| `--success-dim` | Dimmed success background |
|
||||
| `--success-light` | Light success variant |
|
||||
| `--warning` | Warning/caution color |
|
||||
| `--warning-dim` | Dimmed warning background |
|
||||
| `--warning-light` | Light warning variant |
|
||||
| `--purple` | Purple accent |
|
||||
| `--purple-dim` | Dimmed purple background |
|
||||
|
||||
### Spacing
|
||||
|
||||
Use spacing tokens instead of hardcoded values for padding, margin, and gap.
|
||||
For sub-4px values (1px, 2px, 3px) used in borders and fine detail, hardcoded
|
||||
values are acceptable.
|
||||
|
||||
| Variable | Value | Computed |
|
||||
|----------|-------|---------|
|
||||
| `--sp-1` | `0.25rem` | 4px |
|
||||
| `--sp-1h` | `0.375rem` | 6px |
|
||||
| `--sp-2` | `0.5rem` | 8px |
|
||||
| `--sp-2h` | `0.625rem` | 10px |
|
||||
| `--sp-3` | `0.75rem` | 12px |
|
||||
| `--sp-4` | `1rem` | 16px |
|
||||
| `--sp-5` | `1.25rem` | 20px |
|
||||
| `--sp-6` | `1.5rem` | 24px |
|
||||
| `--sp-8` | `2rem` | 32px |
|
||||
| `--sp-10` | `2.5rem` | 40px |
|
||||
| `--sp-12` | `3rem` | 48px |
|
||||
|
||||
Example:
|
||||
|
||||
```css
|
||||
.ext-my-app-card {
|
||||
padding: var(--sp-3) var(--sp-4); /* 12px 16px */
|
||||
gap: var(--sp-2); /* 8px */
|
||||
margin-bottom: var(--sp-4); /* 16px */
|
||||
}
|
||||
```
|
||||
|
||||
### Layout & Typography
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `--font` | Primary font family (self-hosted, no external requests) |
|
||||
| `--mono` | Monospace font family (self-hosted) |
|
||||
| `--radius-sm` | Small border-radius (4px) — badges, inline controls |
|
||||
| `--radius` | Default border-radius (8px) — buttons, inputs, cards |
|
||||
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |
|
||||
| `--transition` | Default transition timing |
|
||||
| `--shadow-lg` | Large elevation shadow |
|
||||
| `--overlay` | Modal overlay color |
|
||||
| `--glass` | Glassmorphism backdrop |
|
||||
| `--input-bg` | Form input background |
|
||||
| `--sidebar-w` | Sidebar width |
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
The kernel uses these standard breakpoints. Extensions should use the same
|
||||
values for consistency.
|
||||
|
||||
| Name | Media Query | Use Case |
|
||||
|------|-------------|----------|
|
||||
| Mobile | `@media (max-width: 768px)` | Phone-sized, single column |
|
||||
| Tablet | `@media (max-width: 1024px)` | Tablet/small laptop, narrower sidebars |
|
||||
| Desktop | Default (no query) | Full layout |
|
||||
|
||||
CSS custom properties cannot be used in `@media` queries — use the pixel
|
||||
values directly.
|
||||
|
||||
---
|
||||
|
||||
## What Is Internal
|
||||
|
||||
Everything not listed above is **internal kernel CSS** and may change between
|
||||
minor versions. Extensions must not depend on:
|
||||
|
||||
- Kernel layout classes (`.admin-*`, `.surface-*`, `.sidebar`, etc.)
|
||||
- Kernel CSS file load order
|
||||
- Specific HTML structure of the shell or topbar
|
||||
- Undocumented CSS variables
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
The linter script `scripts/lint-package-css.sh` runs against all
|
||||
`packages/*/css/main.css` files. It exits non-zero if any rule's first class
|
||||
selector does not start with `.ext-{slug}`.
|
||||
@@ -72,18 +72,34 @@ Every package has a `manifest.json` at its root. Example for a surface:
|
||||
| `icon` | no | Emoji icon for sidebar/menu |
|
||||
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
|
||||
| `auth` | no | `authenticated` (default) or `public` |
|
||||
| `permissions` | no | Capabilities requested: `db.write`, `http`, `notifications`, `secrets`, `realtime.publish` |
|
||||
| `permissions` | no | Sandbox capabilities: `db.write`, `db.read`, `api.http`, `notifications`, `secrets`, `realtime.publish`, `connections.read`, `workflow.access`, `batch.exec`, `files.read`, `files.write`, `workspace.manage` |
|
||||
| `api_routes` | no | Array of `{method, path}` for extension HTTP endpoints |
|
||||
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
||||
| `db_tables` | no | Table definitions (see below) |
|
||||
| `settings` | no | User-configurable settings schema |
|
||||
| `exports` | libraries | Functions exported for other packages |
|
||||
| `exports` | no | Functions exported for cross-package calls via `lib.require()` |
|
||||
| `depends` | no | Array of package IDs this package depends on |
|
||||
| `slots` | no | Named UI injection points for host surfaces |
|
||||
| `contributes` | no | Slot contributions into other surfaces |
|
||||
| `hooks` | no | Event bus subscriptions |
|
||||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||||
| `capabilities` | no | Environment requirements (see below) |
|
||||
| `user_permissions` | no | Permissions this extension registers for users (see below) |
|
||||
| `gate_permission` | no | Permission checked before `on_request` executes |
|
||||
| `schema_version` | no | Integer for additive schema migrations |
|
||||
|
||||
## db_tables Schema
|
||||
|
||||
Tables are automatically namespaced as `ext_{package_id}_{table_name}`. Column types: `text`, `int`. Every table gets an auto-generated `id` primary key and `created_at` timestamp.
|
||||
Tables are automatically namespaced as `ext_{package_id}_{table_name}`.
|
||||
Every table gets an auto-generated `id` primary key and `created_at` timestamp.
|
||||
|
||||
Column types: `text`, `int`, `vector(N)`.
|
||||
|
||||
The `vector(N)` type stores N-dimensional float vectors for similarity
|
||||
search via `db.query_similar()`. Storage adapts to the backend:
|
||||
Postgres + pgvector uses native `vector(N)` with HNSW indexes,
|
||||
Postgres without pgvector uses `JSONB`, SQLite uses `TEXT`. N can be
|
||||
1–4096. See the [Starlark Reference](STARLARK-REFERENCE) for query API.
|
||||
|
||||
```json
|
||||
"db_tables": {
|
||||
@@ -136,26 +152,231 @@ Extensions can optionally declare an `api_schema` array in their manifest to pro
|
||||
|
||||
Only `path` and `method` are required. All other fields are optional. Malformed entries are logged and skipped without blocking extension loading.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Extensions can declare environment requirements via the `capabilities`
|
||||
manifest field. The kernel validates these at install time.
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": {
|
||||
"required": ["postgres"],
|
||||
"optional": ["pgvector", "workspace"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **required** — install is rejected (HTTP 422) if any capability is missing.
|
||||
- **optional** — install succeeds with a logged warning. Query at runtime
|
||||
with `settings.has_capability("pgvector")` to adapt behavior.
|
||||
|
||||
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
|
||||
`postgres`. The admin can view detected capabilities at
|
||||
**Admin > System > Capabilities** or via `GET /admin/capabilities`.
|
||||
|
||||
## User Permissions
|
||||
|
||||
Extensions can register custom permissions that the admin assigns to
|
||||
user groups. This controls access to extension features beyond the
|
||||
sandbox permission model.
|
||||
|
||||
```json
|
||||
{
|
||||
"user_permissions": ["image-gen.use", "image-gen.admin"],
|
||||
"gate_permission": "image-gen.use"
|
||||
}
|
||||
```
|
||||
|
||||
- **user_permissions** — on install, these are merged into the kernel's
|
||||
permission registry. On uninstall, they are removed. The admin assigns
|
||||
them to groups in **Admin > Groups**.
|
||||
- **gate_permission** — if set, the kernel checks this permission before
|
||||
calling `on_request`. Unauthorized users get a 403 without the
|
||||
extension code executing.
|
||||
|
||||
In Starlark, check permissions inline via `req["permissions"]` or call
|
||||
`permissions.check(user_id, "image-gen.use")`.
|
||||
|
||||
## Extension Composability
|
||||
|
||||
Extensions compose with each other through three mechanisms: manifest-declared
|
||||
**slots** (UI injection points), **contributions** (UI components injected into
|
||||
those slots), and cross-package **function calls** via `lib.require()`.
|
||||
|
||||
### Slots — Host Surfaces Declare Injection Points
|
||||
|
||||
A surface declares named slots in its manifest where other extensions can
|
||||
inject UI components:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"type": "surface",
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string",
|
||||
"getContent": "function — returns note body",
|
||||
"setContent": "function — replaces note body"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The surface renders slot contents using the SDK helper:
|
||||
|
||||
```javascript
|
||||
html`<div class="toolbar">
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: note.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text),
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
### Contributions — Extensions Inject UI
|
||||
|
||||
An extension declares which slots it contributes to:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-dictate",
|
||||
"type": "extension",
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text dictation"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In its JavaScript, it registers the component:
|
||||
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-dictate',
|
||||
priority: 200,
|
||||
component: ({ noteId, setContent, getContent }) => {
|
||||
// ... component implementation
|
||||
return html`<button class="sw-btn sw-btn--ghost">🎤</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Contributions are soft-coupled — install order doesn't matter. The admin
|
||||
can view all slots and contributors at **Admin > Packages** or via
|
||||
`GET /api/v1/admin/slots`.
|
||||
|
||||
### Cross-Package Function Calls
|
||||
|
||||
Any package that declares `exports` in its manifest can be called by other
|
||||
packages via `lib.require()`. The caller declares the dependency:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-ai",
|
||||
"depends": ["llm-bridge"],
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
In Starlark:
|
||||
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
result = llm.complete([{"role": "user", "content": prompt}])
|
||||
```
|
||||
|
||||
The called function runs with the *target* package's permissions, not the
|
||||
caller's. This is the same security model as library packages.
|
||||
|
||||
### Slot Naming Convention
|
||||
|
||||
Slot names follow `{host-package-id}:{slot-name}`. The colon separates the
|
||||
namespace from the slot. Standard slots for first-party packages:
|
||||
|
||||
| Slot | Host | Use Case |
|
||||
|------|------|----------|
|
||||
| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting |
|
||||
| `notes:note-footer` | notes | Related items, AI summary |
|
||||
| `chat:composer-tools` | chat | Image gen, file attach |
|
||||
| `chat:message-actions` | chat | Reactions, translate, bookmark |
|
||||
| `chat:image-actions` | chat | Regen, edit, upscale |
|
||||
|
||||
## Starlark Sandbox API
|
||||
|
||||
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin):
|
||||
Starlark scripts run server-side with a 1M operation budget and no
|
||||
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
|
||||
the complete module catalog, function signatures, and permission gates.
|
||||
|
||||
| Module | Permission | API |
|
||||
|--------|-----------|-----|
|
||||
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
|
||||
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
|
||||
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
|
||||
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
|
||||
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
|
||||
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
|
||||
## config_section — Settings Panel Injection
|
||||
|
||||
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages.
|
||||
Packages can inject configuration panels into the Settings, Admin, or
|
||||
Team Admin surfaces. Declare `config_section` in `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"config_section": {
|
||||
"label": "My Config",
|
||||
"icon": "M12 2L2 7l10 5 10-5-10-5z",
|
||||
"component": "js/config.js",
|
||||
"surfaces": ["settings", "admin"],
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `label` | yes | Navigation label shown in the sidebar or tab list |
|
||||
| `icon` | no | SVG path data for the nav icon |
|
||||
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
|
||||
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
|
||||
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. At page load, the backend scans all enabled packages for `config_section`
|
||||
entries targeting the current surface.
|
||||
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
|
||||
3. The frontend dynamically imports the component module and renders it
|
||||
as an additional tab/section.
|
||||
4. The component receives `{ packageId, teamId }` as props.
|
||||
|
||||
**Example component** (`js/config.js`):
|
||||
|
||||
```javascript
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export default function MyConfig({ packageId }) {
|
||||
const [val, setVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
|
||||
}, []);
|
||||
|
||||
return html`<div>
|
||||
<label>API Key</label>
|
||||
<input value=${val} onInput=${e => setVal(e.target.value)} />
|
||||
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions Model
|
||||
|
||||
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions.
|
||||
Extensions declare required permissions in `manifest.json`. The admin
|
||||
must grant each permission before the extension can use the corresponding
|
||||
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
|
||||
|
||||
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
|
||||
model, user permission slugs, and settings cascade.
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
294
docs/FRONTEND-JS-GUIDE.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Frontend JS Guide
|
||||
|
||||
Armature extensions run in the browser using **Preact + htm** — a 3 KB
|
||||
runtime with no build step. The kernel provides a rich SDK at `window.sw`
|
||||
that extensions use for API calls, auth, events, theming, and UI.
|
||||
|
||||
## Getting started
|
||||
|
||||
Extension surfaces are ES modules loaded via `<script type="module">`.
|
||||
The SDK is available on `window.sw` after the `sw:ready` DOM event:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('sw:ready', () => {
|
||||
const { html } = window;
|
||||
const mount = document.getElementById('my-mount');
|
||||
preact.render(html`<${App} />`, mount);
|
||||
});
|
||||
```
|
||||
|
||||
Or use the global `hooks` object for Preact hooks:
|
||||
|
||||
```javascript
|
||||
const { useState, useEffect } = hooks;
|
||||
```
|
||||
|
||||
## SDK modules
|
||||
|
||||
All modules live on the `window.sw` object. They are frozen after boot
|
||||
and available to every extension.
|
||||
|
||||
### sw.api — REST client
|
||||
|
||||
Generic escape hatches for any endpoint:
|
||||
|
||||
```javascript
|
||||
sw.api.get('/api/v1/docs')
|
||||
sw.api.post('/api/v1/teams', { name: 'Eng' })
|
||||
sw.api.put(path, body)
|
||||
sw.api.patch(path, body)
|
||||
sw.api.del(path)
|
||||
sw.api.upload(path, file)
|
||||
sw.api.stream(path, body, signal)
|
||||
```
|
||||
|
||||
All methods auto-inject auth tokens and return unwrapped `data` from
|
||||
`{ data: ... }` response envelopes.
|
||||
|
||||
**Domain namespaces** provide typed CRUD methods:
|
||||
|
||||
| Namespace | Key methods |
|
||||
|-----------|-------------|
|
||||
| `sw.api.auth` | `login`, `register`, `refresh`, `logout` |
|
||||
| `sw.api.teams` | `list`, `get`, `create`, `members`, `workflows`, `assignments` |
|
||||
| `sw.api.workflows` | `list`, `get`, `stages`, `instances`, `advance`, `cancel` |
|
||||
| `sw.api.channels` | `list`, `get`, `create`, `update`, `del` |
|
||||
| `sw.api.notifications` | `list`, `unreadCount`, `markRead`, `markAllRead` |
|
||||
| `sw.api.admin` | Sub-objects for `users`, `teams`, `groups`, `packages`, `backup`, etc. |
|
||||
| `sw.api.users` | `search`, `resolve` |
|
||||
| `sw.api.connections` | `list`, `get`, `create`, `resolve` |
|
||||
| `sw.api.ext(pkgId)` | Scoped client for extension API routes: `get`, `post`, `put`, `del` |
|
||||
|
||||
### sw.auth — Authentication state
|
||||
|
||||
```javascript
|
||||
sw.auth.isAuthenticated // boolean
|
||||
sw.auth.user // { id, username, display_name, email, role, avatar }
|
||||
sw.auth.permissions // Set<string>
|
||||
sw.auth.teams // Array<{ id, name, role }>
|
||||
sw.auth.groups // Array<{ id, name, permissions }>
|
||||
```
|
||||
|
||||
Lifecycle: `sw.auth.login(login, pw)`, `sw.auth.logout()`, `sw.auth.refresh()`.
|
||||
|
||||
### sw.can — RBAC gates
|
||||
|
||||
```javascript
|
||||
sw.can('workflow.create') // true if user has permission
|
||||
sw.isAdmin // true if surface.admin.access granted
|
||||
sw.isTeamAdmin(teamId) // true if admin role in team
|
||||
```
|
||||
|
||||
Use these to conditionally render UI elements.
|
||||
|
||||
### sw.on / sw.off / sw.emit — Event bus
|
||||
|
||||
```javascript
|
||||
const unsub = sw.on('theme.changed', (payload) => { ... });
|
||||
sw.once('auth.login', (user) => { ... });
|
||||
sw.off('theme.changed'); // remove all listeners
|
||||
sw.off('theme.changed', fn); // remove specific listener
|
||||
sw.emit('my.event', { data });
|
||||
```
|
||||
|
||||
The event bus bridges to the WebSocket — server-emitted events
|
||||
(e.g. `notification.created`, `workflow.sla_breach`) arrive here.
|
||||
|
||||
### sw.theme — Theme control
|
||||
|
||||
```javascript
|
||||
sw.theme.current // 'dark' or 'light' (resolved)
|
||||
sw.theme.mode // 'dark', 'light', or 'system'
|
||||
sw.theme.set('dark')
|
||||
sw.theme.on('change', (theme) => { ... })
|
||||
sw.theme.tokens // live CSS variables as camelCase JS object
|
||||
```
|
||||
|
||||
### sw.storage — Namespaced localStorage
|
||||
|
||||
```javascript
|
||||
const store = sw.storage.local('my-extension');
|
||||
store.set('key', { complex: 'value' });
|
||||
store.get('key') // parsed object
|
||||
store.remove('key')
|
||||
store.keys() // ['key', ...]
|
||||
store.clear()
|
||||
```
|
||||
|
||||
### sw.realtime — WebSocket pub/sub
|
||||
|
||||
```javascript
|
||||
const unsub = sw.realtime.subscribe('my-channel', 'item.updated', (data) => { ... });
|
||||
// Or subscribe to all events on a channel:
|
||||
const unsub = sw.realtime.subscribe('my-channel', (event, data) => { ... });
|
||||
```
|
||||
|
||||
### sw.slots — UI slot registry
|
||||
|
||||
Register components into named shell slots (e.g. toolbar areas):
|
||||
|
||||
```javascript
|
||||
const unreg = sw.slots.register('topbar-actions', {
|
||||
id: 'my-button',
|
||||
component: MyButton,
|
||||
priority: 10,
|
||||
});
|
||||
```
|
||||
|
||||
### sw.actions — Named action registry
|
||||
|
||||
```javascript
|
||||
sw.actions.register('copy-link', {
|
||||
handler: async (url) => navigator.clipboard.writeText(url),
|
||||
label: 'Copy Link',
|
||||
icon: 'M12 2...',
|
||||
});
|
||||
await sw.actions.run('copy-link', someUrl);
|
||||
```
|
||||
|
||||
### sw.pipe — Filter pipeline
|
||||
|
||||
Three-stage pipeline for message processing:
|
||||
|
||||
```javascript
|
||||
sw.pipe.pre(10, async (ctx) => { /* pre-process */ return ctx; });
|
||||
sw.pipe.stream(10, async (ctx) => { /* streaming */ return ctx; });
|
||||
sw.pipe.render(10, async (ctx) => { /* post-render */ return ctx; });
|
||||
```
|
||||
|
||||
### sw.renderers — Block and post renderers
|
||||
|
||||
Register custom renderers for fenced code blocks or post-processing:
|
||||
|
||||
```javascript
|
||||
sw.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: /^mermaid$/,
|
||||
render: (code, container) => { /* render diagram */ },
|
||||
});
|
||||
|
||||
sw.renderers.register('linkify', {
|
||||
type: 'post',
|
||||
render: (container) => { /* post-process rendered HTML */ },
|
||||
});
|
||||
```
|
||||
|
||||
### sw.markdown — Unified rendering
|
||||
|
||||
```javascript
|
||||
const html = sw.markdown.renderSync(markdownString, { sanitize: false });
|
||||
await sw.markdown.render(markdownString); // async variant
|
||||
sw.markdown.ready // boolean — true after preload
|
||||
```
|
||||
|
||||
Uses marked + DOMPurify + registered `sw.renderers`.
|
||||
|
||||
### sw.users — Identity resolution
|
||||
|
||||
```javascript
|
||||
const user = await sw.users.resolve(userId);
|
||||
const map = await sw.users.resolveMany([id1, id2]);
|
||||
sw.users.displayName(userObj) // display_name || username || 'Unknown'
|
||||
```
|
||||
|
||||
Results are cached for 60 seconds. Batch fetches use the server's
|
||||
bulk resolve endpoint.
|
||||
|
||||
### sw.testing — Test framework
|
||||
|
||||
For writing package runner tests:
|
||||
|
||||
```javascript
|
||||
sw.testing.suite('CRUD', async (s) => {
|
||||
s.before(async () => { /* setup */ });
|
||||
s.after(async () => { /* cleanup */ });
|
||||
|
||||
s.test('creates item', async (t) => {
|
||||
const resp = await sw.api.post('/api/v1/items', { name: 'test' });
|
||||
t.assert.status(resp, 200);
|
||||
t.assert.ok(resp.id);
|
||||
s.track('item', resp.id); // auto-cleanup in afterAll
|
||||
});
|
||||
});
|
||||
await sw.testing.run();
|
||||
```
|
||||
|
||||
### sw.shell — Topbar API
|
||||
|
||||
The kernel injects a two-slot topbar into every extension surface.
|
||||
Extensions customize it via:
|
||||
|
||||
```javascript
|
||||
sw.shell.topbar.setTitle('My Surface'); // text in left slot
|
||||
sw.shell.topbar.setSlot(html`<${TabBar} />`); // center slot content
|
||||
sw.shell.topbar.hide(); // full-bleed mode
|
||||
sw.shell.topbar.show(); // restore topbar
|
||||
```
|
||||
|
||||
### sw.toast / sw.confirm / sw.prompt — UI primitives
|
||||
|
||||
```javascript
|
||||
sw.toast('Saved!', 'success'); // success | error | info
|
||||
sw.toast('Something broke', 'error', 5000); // custom duration
|
||||
|
||||
const ok = await sw.confirm('Delete this item?');
|
||||
const name = await sw.prompt('Enter name', 'default value');
|
||||
```
|
||||
|
||||
## Shell topbar patterns
|
||||
|
||||
Every surface uses one of three patterns:
|
||||
|
||||
| Pattern | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| **A — Default** | Shell title only. No center slot content. | Docs |
|
||||
| **B — Flat tabs** | `setTitle()` + tabs in center slot via `setSlot()`. Full-width content. | Settings, Team Admin |
|
||||
| **C — Category tabs + sidebar** | `setTitle()` + category tabs in center slot. Surface-owned sidebar below. | Admin |
|
||||
|
||||
The shell provides the home link, notification bell, and user menu
|
||||
on every surface for free.
|
||||
|
||||
## `sw.forms` — Typed Forms (v0.9.5)
|
||||
|
||||
Any extension can render and validate typed forms using the `sw.forms` module.
|
||||
|
||||
### `sw.forms.render(container, template, opts)`
|
||||
|
||||
Renders a typed form into a DOM container. Supports flat forms and progressive
|
||||
multi-step forms (fieldsets). Returns a control handle.
|
||||
|
||||
```js
|
||||
const handle = sw.forms.render(document.getElementById('my-form'), template, {
|
||||
values: { name: 'prefilled' },
|
||||
onSubmit: (data) => { console.log('submitted', data); },
|
||||
});
|
||||
|
||||
// Programmatic access
|
||||
const data = handle.getData();
|
||||
handle.setErrors([{ key: 'name', message: 'Name is taken' }]);
|
||||
handle.destroy();
|
||||
```
|
||||
|
||||
### `sw.forms.validate(template, data)`
|
||||
|
||||
Client-side validation (no network call). Returns `{ valid, errors }`.
|
||||
|
||||
```js
|
||||
const { valid, errors } = sw.forms.validate(template, { name: '' });
|
||||
// valid === false, errors === [{ key: 'name', message: 'Name is required' }]
|
||||
```
|
||||
|
||||
### `sw.forms.validateRemote(template, data)`
|
||||
|
||||
Server-side validation via `POST /api/v1/forms/validate`. Returns a Promise.
|
||||
|
||||
```js
|
||||
const result = await sw.forms.validateRemote(template, data);
|
||||
```
|
||||
|
||||
## Extension CSS contract
|
||||
|
||||
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
||||
conflicts with kernel styles. See the [Extension CSS](EXTENSION-CSS)
|
||||
doc for the full isolation rules, available primitives from
|
||||
`sw-primitives.css`, and spacing tokens.
|
||||
@@ -12,7 +12,7 @@ docker compose up --build
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
|
||||
|
||||
Data persists in the `sb_data` named volume. To reset everything:
|
||||
Data persists in the `armature_data` named volume. To reset everything:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
|
||||
201
docs/MULTI-SURFACE-GUIDE.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Multi-Surface Packages
|
||||
|
||||
A multi-surface package serves multiple pages from a single package, each
|
||||
with its own URL path, access level, and layout. Before v0.9.0, a package
|
||||
got one route (`/s/{id}`), one auth posture, and one layout. Now a single
|
||||
package can serve a public submission form alongside an authenticated
|
||||
dashboard and an admin settings page.
|
||||
|
||||
## When to Use Multi-Surface
|
||||
|
||||
Use multi-surface when your package has logically related pages that share
|
||||
the same backend (Starlark hooks, ext API, database tables) but need:
|
||||
|
||||
- **Different access levels** — public intake form + authenticated dashboard
|
||||
- **Multiple views** — list view, detail view, edit view, monitor view
|
||||
- **Sub-pages** — settings, admin, or debug pages within the package
|
||||
|
||||
If your pages don't share backend state, use separate packages instead.
|
||||
|
||||
## Manifest Setup
|
||||
|
||||
Add a `surfaces` array to your manifest. Each entry declares a page:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "workflow-builder",
|
||||
"title": "Workflow Builder",
|
||||
"type": "full",
|
||||
"version": "0.1.0",
|
||||
"icon": "🔧",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
|
||||
"surfaces": [
|
||||
{ "path": "/", "title": "Workflows" },
|
||||
{ "path": "/new", "title": "New Workflow", "nav": false },
|
||||
{ "path": "/:id/edit", "title": "Edit Workflow", "nav": false },
|
||||
{ "path": "/monitor", "title": "Monitor", "nav": true },
|
||||
{ "path": "/monitor/:id", "title": "Instance Detail", "nav": false }
|
||||
],
|
||||
|
||||
"hooks": ["surface"],
|
||||
"permissions": ["workflow.access"]
|
||||
}
|
||||
```
|
||||
|
||||
This generates five server routes, all under `/s/workflow-builder/`:
|
||||
|
||||
| Route | Access | Nav |
|
||||
|-------|--------|-----|
|
||||
| `/s/workflow-builder/` | authenticated | yes |
|
||||
| `/s/workflow-builder/new` | authenticated | no |
|
||||
| `/s/workflow-builder/:id/edit` | authenticated | no |
|
||||
| `/s/workflow-builder/monitor` | authenticated | yes |
|
||||
| `/s/workflow-builder/monitor/:id` | authenticated | no |
|
||||
|
||||
### Surface Entry Fields
|
||||
|
||||
| Field | Default | Description |
|
||||
|----------|----------------|-------------|
|
||||
| `path` | **required** | URL path relative to `/s/{id}`. Supports `:param` segments. |
|
||||
| `access` | package `auth` | `public`, `authenticated`, `admin`, or `group:{name}`. |
|
||||
| `title` | package `title`| Label shown in nav and page title. |
|
||||
| `layout` | package `layout`| `single` or `editor`. |
|
||||
| `nav` | `true` for `/`, `false` otherwise | Whether this surface appears in the sidebar. |
|
||||
|
||||
### Mixed Access Levels
|
||||
|
||||
A package can mix public and authenticated surfaces:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "bug-tracker",
|
||||
"auth": "authenticated",
|
||||
"surfaces": [
|
||||
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
|
||||
{ "path": "/", "title": "Dashboard" },
|
||||
{ "path": "/:id", "title": "Bug Detail", "nav": false },
|
||||
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The kernel enforces access per-surface. Unauthenticated visitors can reach
|
||||
`/submit` but are redirected to login if they try `/` or `/:id`.
|
||||
|
||||
## Frontend: Routing Within Your Package
|
||||
|
||||
### Reading the Current Surface
|
||||
|
||||
When your package JS loads, two globals tell you which surface was matched:
|
||||
|
||||
```js
|
||||
const path = window.__SURFACE_PATH__ || '/';
|
||||
const params = window.__SURFACE_PARAMS__ || {};
|
||||
```
|
||||
|
||||
Use these to decide which view to render:
|
||||
|
||||
```js
|
||||
function render() {
|
||||
const path = window.__SURFACE_PATH__ || '/';
|
||||
const params = window.__SURFACE_PARAMS__ || {};
|
||||
|
||||
const root = document.getElementById('surface-root');
|
||||
|
||||
switch (path) {
|
||||
case '/': return renderList(root);
|
||||
case '/new': return renderEditor(root, null);
|
||||
case '/:id/edit': return renderEditor(root, params.id);
|
||||
case '/monitor': return renderMonitor(root);
|
||||
case '/monitor/:id':return renderDetail(root, params.id);
|
||||
default: return render404(root);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
```
|
||||
|
||||
### SPA Navigation with `sw.navigate()`
|
||||
|
||||
Navigate between surfaces without a full page reload:
|
||||
|
||||
```js
|
||||
// Navigate to a static path
|
||||
sw.navigate('/new');
|
||||
|
||||
// Navigate with params
|
||||
sw.navigate('/:id/edit', { id: 'wf-42' });
|
||||
|
||||
// Navigate to monitor sub-page
|
||||
sw.navigate('/monitor/:id', { id: 'inst-7' });
|
||||
```
|
||||
|
||||
`sw.navigate()` does three things:
|
||||
1. Updates `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__`
|
||||
2. Calls `history.pushState()` to update the URL
|
||||
3. Emits a `surface.navigate` event
|
||||
|
||||
### Listening for Navigation Events
|
||||
|
||||
Re-render when the user navigates (including browser back/forward):
|
||||
|
||||
```js
|
||||
sw.on('surface.navigate', ({ path, params }) => {
|
||||
window.__SURFACE_PATH__ = path;
|
||||
window.__SURFACE_PARAMS__ = params;
|
||||
render();
|
||||
});
|
||||
```
|
||||
|
||||
### Links Between Surfaces
|
||||
|
||||
For simple `<a>` links that do full page loads:
|
||||
|
||||
```html
|
||||
<a href="/s/workflow-builder/monitor">Monitor</a>
|
||||
```
|
||||
|
||||
For SPA-style navigation:
|
||||
|
||||
```js
|
||||
button.onclick = () => sw.navigate('/monitor');
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
All surfaces in a package share the same ext API. API calls go through
|
||||
`/s/{id}/api/*` regardless of which surface is active:
|
||||
|
||||
```js
|
||||
// These work from any surface in the package
|
||||
const items = await sw.api.get('/items');
|
||||
const item = await sw.api.get(`/items/${id}`);
|
||||
await sw.api.post('/items', { title: 'New item' });
|
||||
```
|
||||
|
||||
The ext API handler checks `package.status == "active"` — if the package
|
||||
is in `pending_review`, all API calls return 403.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
Packages without a `surfaces` array continue to work. The kernel
|
||||
synthesizes a single-entry array from the legacy `auth` and `layout`
|
||||
fields:
|
||||
|
||||
```
|
||||
auth: "authenticated" + layout: "single"
|
||||
→ surfaces: [{ "path": "/", "access": "authenticated", "layout": "single" }]
|
||||
```
|
||||
|
||||
No migration is required for existing packages.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No cross-package routing.** Surface paths are relative to the package
|
||||
mount point. A package cannot claim arbitrary top-level routes.
|
||||
- **No per-surface Starlark hooks.** All surfaces share the same backend
|
||||
hooks. Use the surface path in your hook logic to differentiate.
|
||||
- **No SSR.** Surface rendering is client-side. The kernel serves the
|
||||
shell template; your JS renders the content.
|
||||
@@ -60,16 +60,140 @@ Only `manifest.json` is required. All other directories are optional and include
|
||||
| `description` | Short description |
|
||||
| `icon` | Emoji for sidebar/menu display |
|
||||
| `author` | Package author |
|
||||
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
|
||||
| `auth` | `authenticated` or `public` |
|
||||
| `layout` | Surface layout mode (e.g., `single`) |
|
||||
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
|
||||
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
|
||||
| `auth` | Default access level for all surfaces: `authenticated`, `public`, `admin` |
|
||||
| `layout` | Default layout for all surfaces: `single`, `editor` |
|
||||
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
||||
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
||||
| `db_tables` | Table definitions with columns and indexes |
|
||||
| `settings` | User-configurable settings with type, label, description, default |
|
||||
| `hooks` | Event bus subscription patterns |
|
||||
| `exports` | Functions exported by library packages |
|
||||
| `exports` | Functions exported for cross-package calls via `lib.require()` |
|
||||
| `depends` | Array of package IDs this package depends on |
|
||||
| `slots` | Named UI injection points (host surfaces declare these) |
|
||||
| `contributes` | Slot contributions this package injects into other surfaces |
|
||||
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||
| `schema_version` | Integer for additive schema migrations |
|
||||
| `form_template` | Typed form template (v0.9.5). Fields array or fieldsets for progressive forms. Validated at install. |
|
||||
|
||||
## Multi-Surface Packages
|
||||
|
||||
A package can serve multiple pages, each with its own path, access level,
|
||||
title, and layout. Declare a `surfaces` array in the manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "bug-tracker",
|
||||
"title": "Bug Tracker",
|
||||
"type": "full",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"surfaces": [
|
||||
{ "path": "/", "title": "Dashboard" },
|
||||
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
|
||||
{ "path": "/:id", "title": "Bug Detail", "nav": false },
|
||||
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Surface Entry Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|----------|--------|----------------|-------------|
|
||||
| `path` | string | **required** | Relative to `/s/{pkg-id}`. Supports `:param` segments. |
|
||||
| `access` | string | package `auth` | `public`, `authenticated`, `admin`, `group:{name}`. |
|
||||
| `title` | string | package `title`| Human label. Used in nav if `nav: true`. |
|
||||
| `layout` | string | package `layout`| `single`, `editor`, or future layouts. |
|
||||
| `nav` | bool | see rules | Show in sidebar navigation. |
|
||||
|
||||
### Nav Visibility Rules
|
||||
|
||||
- `path: "/"` defaults to `nav: true` (primary entry point)
|
||||
- All others default to `nav: false` (sub-pages)
|
||||
- Explicit `"nav": true` overrides — a package can put multiple entries in nav
|
||||
- The sidebar links to the first surface with `nav: true`
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
If `surfaces` is absent, the kernel synthesizes one entry from the legacy
|
||||
`auth` and `layout` fields. No existing packages break.
|
||||
|
||||
### Client-Side Navigation
|
||||
|
||||
Within a multi-surface package, use `sw.navigate()` for SPA-style routing:
|
||||
|
||||
```js
|
||||
const path = window.__SURFACE_PATH__ || '/';
|
||||
const params = window.__SURFACE_PARAMS__ || {};
|
||||
|
||||
if (path === '/') renderDashboard();
|
||||
if (path === '/submit') renderSubmitForm();
|
||||
if (path === '/:id') renderDetail(params.id);
|
||||
|
||||
// Navigate to another surface within the package
|
||||
sw.navigate('/submit');
|
||||
sw.navigate('/:id', { id: 'bug-42' });
|
||||
|
||||
// Listen for navigation events (including back/forward)
|
||||
sw.on('surface.navigate', ({ path, params }) => {
|
||||
renderView(path, params);
|
||||
});
|
||||
```
|
||||
|
||||
## Composability: Slots and Contributions
|
||||
|
||||
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
|
||||
|
||||
### Declaring Slots (Host Surfaces)
|
||||
|
||||
Surfaces declare slots where other extensions can inject UI:
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string — current note ID",
|
||||
"getContent": "function — returns note body text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slot names are namespaced at runtime as `{package-id}:{slot-name}` (e.g., `notes:toolbar-actions`). The `context` field documents what data the slot provides — it is not enforced at runtime.
|
||||
|
||||
### Contributing to Slots
|
||||
|
||||
Extensions declare which slots they inject into:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text dictation"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at `GET /api/v1/admin/slots`.
|
||||
|
||||
### Cross-Package Function Calls
|
||||
|
||||
Any package that declares `exports` can be called via `lib.require()`, not just library-type packages. The caller must declare the target in its `depends` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"depends": ["image-gen"],
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
## Package Lifecycle
|
||||
|
||||
|
||||
121
docs/PERMISSIONS-AND-GROUPS.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Permissions & Groups
|
||||
|
||||
Armature uses group-based RBAC. Permissions are granted to groups, and users
|
||||
inherit the union of permissions from all groups they belong to. There are no
|
||||
per-user permission grants — all access flows through group membership.
|
||||
|
||||
## Groups
|
||||
|
||||
### System groups
|
||||
|
||||
| Group | ID | Purpose |
|
||||
|-------|----|---------|
|
||||
| Everyone | `00000000-...0001` | Implicit membership for every authenticated user. Default permissions: `extension.use`, `workflow.submit`. |
|
||||
| Admins | `00000000-...0002` | Full platform access. Members receive all seven permission slugs. Replaces the legacy `role = admin` check. |
|
||||
|
||||
Every new user is automatically added to **Everyone** on registration.
|
||||
Admin status is granted by adding a user to the **Admins** group in
|
||||
**Admin > People > Groups**.
|
||||
|
||||
### Custom groups
|
||||
|
||||
Administrators can create additional groups under **Admin > People > Groups**.
|
||||
Each custom group has:
|
||||
|
||||
- **Name** — display label
|
||||
- **Description** — purpose (shown in admin UI)
|
||||
- **Scope** — always `global` (team-scoped groups reserved for future use)
|
||||
- **Permissions** — zero or more permission slugs from the table below
|
||||
|
||||
## Permission slugs
|
||||
|
||||
Seven platform permissions control access to kernel features:
|
||||
|
||||
| Slug | Description |
|
||||
|------|-------------|
|
||||
| `surface.admin.access` | Full admin panel access (tabs, settings, package management) |
|
||||
| `admin.view` | Read-only admin panel access (monitoring, health, audit log) |
|
||||
| `extension.use` | Use installed extension surfaces and libraries |
|
||||
| `extension.install` | Install, update, enable, and disable packages |
|
||||
| `workflow.create` | Create and edit workflow definitions |
|
||||
| `workflow.submit` | Submit instances to public-link workflows |
|
||||
| `token.unlimited` | Bypass per-user token budgets (API rate limiting) |
|
||||
|
||||
Permissions follow a `domain.action` naming convention.
|
||||
|
||||
## Permission resolution
|
||||
|
||||
When a request arrives, the kernel resolves the effective permission set:
|
||||
|
||||
1. Fetch all groups the user belongs to (including Everyone).
|
||||
2. Union all permission arrays across those groups.
|
||||
3. Cache the result for the duration of the request.
|
||||
|
||||
A user has a permission if **any** of their groups grants it.
|
||||
|
||||
Frontend code checks permissions via `sw.can('slug')` — see the
|
||||
[Frontend JS Guide](FRONTEND-JS-GUIDE) for details.
|
||||
|
||||
## Extension permissions
|
||||
|
||||
Separate from user permissions, each **package** can request sandbox
|
||||
capabilities. These are granted per-package in **Admin > Packages**:
|
||||
|
||||
| Permission | Grants |
|
||||
|------------|--------|
|
||||
| `db.read` | Query `ext_data` tables (read-only) |
|
||||
| `db.write` | Insert, update, and delete rows in `ext_data` tables |
|
||||
| `api.http` | Make outbound HTTP requests from Starlark |
|
||||
| `notifications.send` | Send in-app notifications to users |
|
||||
| `secrets.read` | Read admin-configured extension secrets |
|
||||
| `realtime.publish` | Publish WebSocket events to subscribed clients |
|
||||
| `connections.read` | Read external connection configs (decrypted) |
|
||||
| `workflow.access` | Read workflow definitions and instances |
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for how these
|
||||
map to sandbox modules.
|
||||
|
||||
## Settings cascade
|
||||
|
||||
Package settings use a three-tier resolution model:
|
||||
|
||||
```
|
||||
user override → team override → global default
|
||||
```
|
||||
|
||||
At each tier:
|
||||
|
||||
- **Global** — set by admins in **Admin > Packages > Settings**
|
||||
- **Team** — set by team admins in **Team Admin > Settings**
|
||||
- **User** — set by users in **Settings > Extensions**
|
||||
|
||||
### The `user_overridable` flag
|
||||
|
||||
Each setting key in a package manifest can declare `user_overridable`:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{ "key": "theme", "user_overridable": true },
|
||||
{ "key": "api_endpoint", "user_overridable": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `true` (default) — team and user scopes can override the global value.
|
||||
- `false` — only the global (admin) value is used. Team and user values
|
||||
are silently ignored during resolution.
|
||||
|
||||
This gives administrators a lock mechanism: set `user_overridable: false`
|
||||
on security-sensitive keys to prevent lower scopes from changing them,
|
||||
while allowing cosmetic preferences to flow freely.
|
||||
|
||||
### Resolution algorithm
|
||||
|
||||
1. Start with the global value for each key.
|
||||
2. For each key where `user_overridable` is true (or undeclared):
|
||||
- If a team-scoped value exists, it overrides global.
|
||||
- If a user-scoped value exists, it overrides team.
|
||||
3. For keys where `user_overridable` is false:
|
||||
- Team and user values are discarded.
|
||||
4. Unknown keys (not in schema) default to overridable.
|
||||
171
docs/ROADMAP-v010x-shift.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# ROADMAP — v0.10.x+ Version Shift (Final)
|
||||
|
||||
## Summary
|
||||
|
||||
Four new series inserted after v0.9.x. Panels (kernel primitive), Notes
|
||||
(first reference extension), Chat (second reference extension), and the
|
||||
remaining reference libraries/extensions including `llm-bridge` with
|
||||
the tool meta-tool pattern.
|
||||
|
||||
## Full Roadmap
|
||||
|
||||
| Series | Title | Versions | Design Doc |
|
||||
|--------|-------|----------|------------|
|
||||
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||
| v0.14.x | Sidecar Tier + Polish | 3 | — |
|
||||
| v1.0.0 | Stable Release | — | — |
|
||||
|
||||
---
|
||||
|
||||
## v0.10.x — Panels + Composable Layout
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||
| v0.10.1 | FloatingPanel Primitive |
|
||||
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||
| v0.10.3 | Panel Communication Patterns |
|
||||
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||
|
||||
---
|
||||
|
||||
## v0.11.x — Notes Reference Extension
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.11.0 | UI/UX Foundation |
|
||||
| v0.11.1 | Deep Folders + Navigation |
|
||||
| v0.11.2 | Wikilinks + Backlinks |
|
||||
| v0.11.3 | Live Preview + Rich Editing |
|
||||
| v0.11.4 | Note Sharing + Permissions |
|
||||
| v0.11.5 | Graph + Outline Hardening |
|
||||
| v0.11.6 | Quick Switcher + Commands |
|
||||
| v0.11.7 | Daily Notes + Templates |
|
||||
| v0.11.8 | Transclusion + Embeds |
|
||||
| v0.11.9 | Composability: Slots + Actions |
|
||||
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.12.x — Chat Reference Extension
|
||||
|
||||
Human-to-human first. AI via `llm-bridge` (v0.13.x).
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.12.0 | UI/UX Foundation |
|
||||
| v0.12.1 | Conversation Folders + Attributes |
|
||||
| v0.12.2 | Reactions + Threads + Pins |
|
||||
| v0.12.3 | Rich Compose + Attachments |
|
||||
| v0.12.4 | @Mentions + Notifications |
|
||||
| v0.12.5 | Link Previews + Message Formatting |
|
||||
| v0.12.6 | Conversation Themes + Personality |
|
||||
| v0.12.7 | Composability: Slots + Actions |
|
||||
| v0.12.8 | Panels + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.13.x — Reference Libraries + Extensions
|
||||
|
||||
Core libraries and remaining extensions. `llm-bridge` is the key
|
||||
delivery — extends both notes and chat through composability primitives,
|
||||
and introduces the tool meta-tool pattern.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.13.0 | `vector-store` Library |
|
||||
| v0.13.1 | `llm-bridge` Core Library |
|
||||
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||
| v0.13.4 | `file-share` Extension |
|
||||
| v0.13.5 | `code-workspace` Extension |
|
||||
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||
| v0.13.8 | Integration Quality Gate |
|
||||
|
||||
**v0.13.1 — `llm-bridge` Core:**
|
||||
Model abstraction, provider BYOK via connections, `complete()`,
|
||||
`embed()`, `classify()`. Persona CRUD (name, avatar, system_prompt,
|
||||
visibility). Layered prompt architecture (6 layers: admin safety →
|
||||
folder context → persona identity → conversation → tools → user).
|
||||
Admin safety rails as non-overridable platform setting.
|
||||
|
||||
**v0.13.2 — Chat Integration:**
|
||||
Contributes folder attributes (system_prompt, context_policy,
|
||||
model_override, allowed_tools) to chat. Contributes "Invite AI" /
|
||||
"Dismiss AI" to `chat:participant-actions`. Contributes "AI Reply" /
|
||||
"AI Summarize" to `chat:message-actions`. Contributes "Ask AI" to
|
||||
`chat:composer-tools`. Context archetype implementation (resident /
|
||||
scoped / stateless). Session tracking, gap handling, memory extraction
|
||||
on dismiss. **Tool meta-tool v1:** `sw.actions.list()` → LLM tool
|
||||
definitions. AI calls extension actions, results flow back into chat.
|
||||
|
||||
**v0.13.3 — Notes Integration:**
|
||||
Contributes "AI Summarize" / "AI Translate" / "AI Fix Grammar" to
|
||||
`notes:toolbar-actions`. Contributes `/ai` to `notes:slash-commands`.
|
||||
Uses `notes.get` / `notes.search` actions for context.
|
||||
|
||||
**v0.13.7 — Tool Meta-Tool Hardening:**
|
||||
Three-tier tool scoping: admin blocklist (global), folder allowed_tools
|
||||
(space-level), persona allow/deny (character-level). Tool result
|
||||
rendering through `sw.renderers`. Rate limiting on tool calls. Audit
|
||||
logging of tool use. Error handling (tool failure → graceful message).
|
||||
|
||||
**v0.13.8 — Integration Quality Gate:**
|
||||
AI participant lifecycle tested end-to-end. Tool meta-tool demonstrated
|
||||
with 3+ extensions. All three context archetypes tested. Persona
|
||||
creation/sharing across visibility levels. Admin safety rails validated
|
||||
(prompt injection resistance). Performance: completion latency measured.
|
||||
|
||||
---
|
||||
|
||||
## v0.14.x — Sidecar Tier + Polish
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.14.0 | Sidecar Tier |
|
||||
| v0.14.1 | Native Dialog Audit |
|
||||
| v0.14.2 | Stability + Migration Tooling |
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 Gate Criteria
|
||||
|
||||
- Notes reference extension shipped (all v0.11.x)
|
||||
- Chat reference extension shipped (all v0.12.x)
|
||||
- `llm-bridge` extends both notes and chat through composability
|
||||
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||
single conversation turn
|
||||
- Notes and chat UI reviewed against design principles
|
||||
- At least 2 panels consumed cross-package
|
||||
- At least 2 slot contributions per host surface demonstrated
|
||||
- Multi-persona context archetypes demonstrated
|
||||
- Admin safety rails validated
|
||||
- Note and conversation sharing functional end-to-end
|
||||
- Headless E2E green on PG + SQLite
|
||||
- All kernel Starlark modules documented
|
||||
- All API routes covered by OpenAPI spec
|
||||
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||
- Single-binary + Docker + K8s deployment paths documented
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions Log
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. |
|
||||
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||
| Chat human-to-human first | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||
| Background images via CSS filter | No server-side processing. `filter: blur() brightness() saturate()` + `opacity` handles any image. |
|
||||
| Soft panel deps for notes↔chat | Runtime-resolved, no circular dependency. Both function independently. |
|
||||
| llm-bridge after notes + chat | Proves the composability hooks work without being designed for a specific consumer. |
|
||||
157
docs/ROADMAP-v010x-v100.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# ROADMAP — v0.10.x → v1.0.0 (Final)
|
||||
|
||||
## Full Roadmap
|
||||
|
||||
| Series | Title | Versions | Design Doc |
|
||||
|--------|-------|----------|------------|
|
||||
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||
| **v0.14.x** | **Sidecar Tier** | 6 | `DESIGN-sidecar-v014x.md` |
|
||||
| **v0.15.x** | **Polish + Stability** | 3 | — |
|
||||
| **v1.0.0** | **Stable Release** | — | — |
|
||||
|
||||
**Total: ~53 versions from v0.10.0 to v1.0.0**
|
||||
|
||||
---
|
||||
|
||||
## v0.10.x — Panels + Composable Layout
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||
| v0.10.1 | FloatingPanel Primitive |
|
||||
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||
| v0.10.3 | Panel Communication Patterns |
|
||||
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||
|
||||
---
|
||||
|
||||
## v0.11.x — Notes Reference Extension
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.11.0 | UI/UX Foundation |
|
||||
| v0.11.1 | Deep Folders + Navigation |
|
||||
| v0.11.2 | Wikilinks + Backlinks |
|
||||
| v0.11.3 | Live Preview + Rich Editing |
|
||||
| v0.11.4 | Note Sharing + Permissions |
|
||||
| v0.11.5 | Graph + Outline Hardening |
|
||||
| v0.11.6 | Quick Switcher + Commands |
|
||||
| v0.11.7 | Daily Notes + Templates |
|
||||
| v0.11.8 | Transclusion + Embeds |
|
||||
| v0.11.9 | Composability: Slots + Actions |
|
||||
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.12.x — Chat Reference Extension
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.12.0 | UI/UX Foundation |
|
||||
| v0.12.1 | Conversation Folders + Attributes |
|
||||
| v0.12.2 | Reactions + Threads + Pins |
|
||||
| v0.12.3 | Rich Compose + Attachments |
|
||||
| v0.12.4 | @Mentions + Notifications |
|
||||
| v0.12.5 | Link Previews + Message Formatting |
|
||||
| v0.12.6 | Conversation Themes + Personality |
|
||||
| v0.12.7 | Composability: Slots + Actions |
|
||||
| v0.12.8 | Panels + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.13.x — Reference Libraries + Extensions
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.13.0 | `vector-store` Library |
|
||||
| v0.13.1 | `llm-bridge` Core Library |
|
||||
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona + Tool Meta-Tool |
|
||||
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||
| v0.13.4 | `file-share` Extension |
|
||||
| v0.13.5 | `code-workspace` Extension |
|
||||
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||
| v0.13.8 | Integration Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.14.x — Sidecar Tier
|
||||
|
||||
Connect-inward model. Instance sidecars (shared infrastructure) and
|
||||
user sidecars (personal local tools).
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.14.0 | Sidecar Registry + Auth |
|
||||
| v0.14.1 | Capability Registration + Execution |
|
||||
| v0.14.2 | Kernel API Access + Event Bus |
|
||||
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.15.x — Polish + Stability
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.15.0 | Native Dialog Audit |
|
||||
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 Gate Criteria
|
||||
|
||||
**Reference extensions:**
|
||||
- Notes shipped (all v0.11.x) with UI quality review
|
||||
- Chat shipped (all v0.12.x) with UI quality review
|
||||
- At least 3 reference extensions beyond notes/chat
|
||||
|
||||
**Composability:**
|
||||
- `llm-bridge` extends both notes and chat through slots/actions
|
||||
- Tool meta-tool: AI uses 3+ extension actions in a single turn
|
||||
- At least 2 panels consumed cross-package
|
||||
- At least 2 slot contributions per host surface
|
||||
|
||||
**Sidecar:**
|
||||
- Instance sidecar (`armature-embed`) deployed and functional
|
||||
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||
- Token + mTLS auth both tested
|
||||
- User sidecar RBAC enforced (permission, capability allowlist, scoping)
|
||||
- Tool meta-tool includes user sidecar capabilities per-user
|
||||
|
||||
**Infrastructure:**
|
||||
- Admin safety rails validated
|
||||
- Multi-persona context archetypes demonstrated
|
||||
- Sharing functional (notes + conversations)
|
||||
- Headless E2E green on PG + SQLite
|
||||
- All kernel Starlark modules documented
|
||||
- All API routes covered by OpenAPI spec
|
||||
- `armature migrate` CLI functional
|
||||
- Upgrade path tested v0.8.0 → v1.0.0
|
||||
- Single-binary + Docker + K8s deployment paths documented
|
||||
- Monitoring dashboard with kernel + sidecar metrics
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions Log
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Panels as kernel primitive | Z-index, drag, layout are kernel concerns |
|
||||
| UI/UX-first for reference extensions | Every feature builds on visual foundation |
|
||||
| Notes → Chat → Libraries → Sidecar | Each builds on proven patterns from previous |
|
||||
| Chat human-to-human first | AI is llm-bridge's job |
|
||||
| Folder attributes as extension bridge | Zero coupling between chat and llm-bridge |
|
||||
| Action registry as tool registry | Installed extensions = AI capabilities |
|
||||
| 6-layer prompt architecture | Admin safety not overridable |
|
||||
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery |
|
||||
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract |
|
||||
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism |
|
||||
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility |
|
||||
| Separate polish (v0.15.x) | Security-critical infra and quality-of-life shouldn't share focus |
|
||||
476
docs/STARLARK-REFERENCE.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Starlark Reference
|
||||
|
||||
Armature extensions can include Starlark scripts for server-side logic.
|
||||
Starlark is a Python-like language designed for configuration and
|
||||
embedding — see the [official spec](https://github.com/google/starlark-go).
|
||||
|
||||
## Sandbox constraints
|
||||
|
||||
- **No `while` loops** — use `for` with bounded ranges.
|
||||
- **No `load()`** — use `lib.require()` for library dependencies.
|
||||
- **Max steps:** 1,000,000 bytecode operations per execution.
|
||||
- **No filesystem or OS access** — all I/O goes through gated modules.
|
||||
- **Deterministic** — same input produces same output (no `random`, no `time`).
|
||||
|
||||
## Always-available modules
|
||||
|
||||
These modules are injected into every script with no permission required.
|
||||
|
||||
### json
|
||||
|
||||
Standard Starlark JSON module.
|
||||
|
||||
```python
|
||||
data = json.decode('{"key": "value"}')
|
||||
text = json.encode({"key": "value"})
|
||||
```
|
||||
|
||||
### settings
|
||||
|
||||
Read resolved package settings (global → team → user cascade).
|
||||
|
||||
```python
|
||||
val = settings.get("theme", "light")
|
||||
# Returns the resolved value, or the default if unset.
|
||||
```
|
||||
|
||||
The cascade respects the `user_overridable` flag from the package manifest.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.
|
||||
|
||||
```python
|
||||
# Check if a runtime capability is available
|
||||
if settings.has_capability("pgvector"):
|
||||
# Use native vector search
|
||||
...
|
||||
```
|
||||
|
||||
`has_capability(name)` returns `True` if the named environment capability
|
||||
is detected by the kernel. Detected capabilities: `pgvector`, `workspace`,
|
||||
`object_storage`, `s3`, `postgres`.
|
||||
|
||||
### lib
|
||||
|
||||
Load exported functions from other packages.
|
||||
|
||||
```python
|
||||
helpers = lib.require("my-utils")
|
||||
result = helpers.format_date("2026-01-15")
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- The target package must be declared in your manifest's `depends` array.
|
||||
- The target package must declare `exports` in its manifest.
|
||||
- The target package must be status `active`, tier `starlark`.
|
||||
- Any package type (`library`, `extension`, `full`) can be called as long
|
||||
as it declares exports. This enables full packages to expose callable
|
||||
functions alongside their UI and API routes.
|
||||
- Circular dependencies are detected and rejected.
|
||||
- Results are cached per execution (calling `require` twice returns the
|
||||
same object).
|
||||
- The called function runs with the *target* package's permissions, not
|
||||
the caller's.
|
||||
|
||||
### permissions
|
||||
|
||||
Check whether a user has a specific permission.
|
||||
|
||||
```python
|
||||
if permissions.check(user_id, "image-gen.use"):
|
||||
# User is authorized
|
||||
...
|
||||
```
|
||||
|
||||
Returns `True` if the user has the permission, `False` otherwise (including
|
||||
when the user is not found). Resolves the user's groups and merges granted
|
||||
permissions — works for both kernel and extension-declared permissions.
|
||||
|
||||
### routing
|
||||
|
||||
Generic rule-based decision engine. Evaluates an ordered list of conditions
|
||||
against a data dict, returning the first matching rule's target string.
|
||||
|
||||
```python
|
||||
result = routing.evaluate([
|
||||
{"field": "priority", "op": "eq", "value": "critical", "target": "escalation"},
|
||||
{"field": "amount", "op": "gt", "value": 10000, "target": "manager_review"},
|
||||
{"field": "region", "op": "in", "value": ["EU", "UK"], "target": "gdpr_flow"},
|
||||
], stage_data)
|
||||
# Returns "escalation", "manager_review", "gdpr_flow", or None
|
||||
```
|
||||
|
||||
Each rule is a dict with `field`, `op`, `value`, and `target`. Operators:
|
||||
`exists`, `not_exists`, `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`,
|
||||
`contains`. First-match-wins; returns `None` if no rule matches.
|
||||
|
||||
## Permission-gated modules
|
||||
|
||||
These modules are only available if the package has the corresponding
|
||||
permission granted in **Admin > Packages**.
|
||||
|
||||
### secrets
|
||||
|
||||
**Permission:** `secrets.read`
|
||||
|
||||
Read admin-configured secrets for this package.
|
||||
|
||||
```python
|
||||
api_key = secrets.get("OPENAI_KEY") # str or None
|
||||
all_keys = secrets.list() # list of key names
|
||||
```
|
||||
|
||||
Secrets are set in **Admin > Packages > Secrets** and scoped per package.
|
||||
|
||||
### notifications
|
||||
|
||||
**Permission:** `notifications.send`
|
||||
|
||||
Send in-app notifications to users.
|
||||
|
||||
```python
|
||||
notifications.send(
|
||||
user_id, # str — target user UUID
|
||||
title, # str — notification title
|
||||
body="", # str — optional body text
|
||||
type="extension.notify" # str — notification type
|
||||
)
|
||||
```
|
||||
|
||||
### db
|
||||
|
||||
**Permission:** `db.read` (queries) or `db.write` (mutations)
|
||||
|
||||
Read and write extension data tables. All tables are automatically
|
||||
namespaced as `ext_{package_id}_{table_name}`.
|
||||
|
||||
#### Read operations
|
||||
|
||||
```python
|
||||
# Query with filters, ordering, and pagination
|
||||
rows = db.query(
|
||||
"tasks", # table name (without prefix)
|
||||
filters={"status": "open"}, # equality WHERE clauses
|
||||
order="-created_at", # column name (prefix - for DESC)
|
||||
limit=50, # max 1000
|
||||
before={"created_at": ts}, # range: column < value
|
||||
after={"created_at": ts}, # range: column > value
|
||||
search_like={"title": "%bug%"} # LIKE/ILIKE search
|
||||
)
|
||||
|
||||
# Read from system views (read-only)
|
||||
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
|
||||
channels = db.view("channels", limit=100)
|
||||
|
||||
# List all tables owned by this package
|
||||
tables = db.list_tables()
|
||||
```
|
||||
|
||||
Available views: `users`, `channels`.
|
||||
|
||||
#### Aggregate operations
|
||||
|
||||
```python
|
||||
# Count rows matching filters
|
||||
count = db.count("tasks", filters={"status": "open"})
|
||||
|
||||
# Aggregate a column (sum, avg, min, max, count)
|
||||
total = db.aggregate("orders", "amount", "sum", filters={"status": "paid"})
|
||||
# Returns int, float, or None (if no matching rows)
|
||||
|
||||
# Batch multiple queries in a single call
|
||||
results = db.query_batch([
|
||||
{"table": "tasks", "filters": {"status": "open"}, "limit": 10},
|
||||
{"table": "logs", "order": "-created_at", "limit": 5},
|
||||
])
|
||||
# Returns list of result lists. Max 10 queries per batch.
|
||||
# Each query spec supports: table (required), filters, order, limit, before, after, search_like
|
||||
```
|
||||
|
||||
#### Vector similarity search
|
||||
|
||||
```python
|
||||
# Find rows with the most similar embeddings (cosine distance)
|
||||
rows = db.query_similar(
|
||||
"documents", # table name
|
||||
"embedding", # vector column name
|
||||
vector=[0.1, 0.2, ...], # query vector (list of floats)
|
||||
limit=10, # max results (default 10, max 100)
|
||||
filters={"active": True}, # optional equality filters
|
||||
metric="cosine", # only "cosine" supported
|
||||
)
|
||||
# Returns rows ordered by ascending _distance (0.0 = identical, 1.0 = orthogonal)
|
||||
# Each row dict includes an injected "_distance" float key.
|
||||
```
|
||||
|
||||
Vector columns are declared as `"vector(N)"` in the manifest `db_tables` block
|
||||
(N = dimension, 1–4096). Storage varies by backend:
|
||||
|
||||
| Backend | Column type | Search |
|
||||
|---------|-------------|--------|
|
||||
| Postgres + pgvector | `vector(N)` with HNSW index | Native `<=>` operator |
|
||||
| Postgres (no pgvector) | `JSONB` | Go-side cosine computation |
|
||||
| SQLite | `TEXT` | Go-side cosine computation |
|
||||
|
||||
Insert vectors as lists: `db.insert("docs", {"embedding": [0.1, 0.2, 0.3]})`.
|
||||
|
||||
#### Write operations
|
||||
|
||||
```python
|
||||
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
|
||||
# Returns the inserted row dict (with generated id, created_at)
|
||||
|
||||
db.update("tasks", row_id, {"status": "closed"})
|
||||
# Returns True on success
|
||||
|
||||
db.delete("tasks", row_id)
|
||||
# Returns True on success
|
||||
```
|
||||
|
||||
### http
|
||||
|
||||
**Permission:** `api.http`
|
||||
|
||||
Make outbound HTTP requests.
|
||||
|
||||
```python
|
||||
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
|
||||
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
|
||||
resp = http.put(url, body="...", headers={})
|
||||
resp = http.delete(url, headers={})
|
||||
resp = http.request("PATCH", url, body="...", headers={})
|
||||
```
|
||||
|
||||
Response dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"status": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body": "..." # capped at 1 MB
|
||||
}
|
||||
```
|
||||
|
||||
#### Batch requests
|
||||
|
||||
```python
|
||||
responses = http.batch([
|
||||
{"method": "GET", "url": "https://api.example.com/a"},
|
||||
{"method": "POST", "url": "https://api.example.com/b", "body": "{}", "headers": {"Content-Type": "application/json"}},
|
||||
])
|
||||
# Returns list of response dicts (same shape as individual calls).
|
||||
# Individual failures return {"status": 0, "body": "error: ...", "headers": {}}.
|
||||
# Max 10 requests per batch. Dispatched concurrently.
|
||||
```
|
||||
|
||||
**Security:**
|
||||
- Private/loopback IPs are blocked (SSRF protection).
|
||||
- Packages can declare `network_access.allow` (allowlist) or
|
||||
`network_access.block` (blocklist) in their manifest.
|
||||
- Max 10 redirects. 10-second timeout. 1 MB response body limit.
|
||||
|
||||
### realtime
|
||||
|
||||
**Permission:** `realtime.publish`
|
||||
|
||||
Publish WebSocket events to subscribed clients.
|
||||
|
||||
```python
|
||||
realtime.publish(
|
||||
"my-channel", # channel name
|
||||
"item.updated", # event label
|
||||
{"id": "abc"} # payload dict (max 7 KB)
|
||||
)
|
||||
```
|
||||
|
||||
The payload is automatically tagged with `_pkg: package_id`.
|
||||
|
||||
### connections
|
||||
|
||||
**Permission:** `connections.read`
|
||||
|
||||
Read external connection configurations (secrets are decrypted).
|
||||
|
||||
```python
|
||||
conn = connections.get("postgres", "main-db")
|
||||
# Returns dict with id, type, name, scope, plus flattened config fields
|
||||
# Returns None if not found
|
||||
|
||||
all_pg = connections.list("postgres")
|
||||
# Returns list of connection dicts
|
||||
```
|
||||
|
||||
Connections are resolved via scope chain: personal → team → global.
|
||||
|
||||
### workflow
|
||||
|
||||
**Permission:** `workflow.access`
|
||||
|
||||
Read workflow definitions and instances (read-only from Starlark;
|
||||
mutations go through the HTTP API).
|
||||
|
||||
```python
|
||||
defn = workflow.get_definition(workflow_id)
|
||||
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]
|
||||
|
||||
inst = workflow.get_instance(instance_id)
|
||||
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...
|
||||
|
||||
instances = workflow.list_instances(workflow_id, status="active")
|
||||
# Returns list of instance dicts
|
||||
```
|
||||
|
||||
### batch
|
||||
|
||||
**Permission:** `batch.exec`
|
||||
|
||||
Run multiple callables concurrently. Each callable gets its own
|
||||
execution thread with an independent step budget.
|
||||
|
||||
```python
|
||||
jira = lib.require("jira-client")
|
||||
confluence = lib.require("confluence-client")
|
||||
|
||||
results, errors = batch.exec([
|
||||
lambda: jira.create_issue(issue_data),
|
||||
lambda: confluence.create_page(page_data),
|
||||
lambda: send_notification(user_id),
|
||||
], timeout=15)
|
||||
|
||||
# results[i] = return value of callables[i], or None on error
|
||||
# errors[i] = None on success, or error string on failure
|
||||
# All three ran concurrently.
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Max 8 callables per call. Dispatched concurrently via goroutines.
|
||||
- `timeout` (optional): 1–30 seconds per branch (default 10).
|
||||
- `lib.require()` is not available inside branch callables.
|
||||
Load libraries before the `batch.exec` call.
|
||||
- `batch.exec()` cannot be called from within a branch (no nesting).
|
||||
- `print()` output from branches is discarded.
|
||||
|
||||
---
|
||||
|
||||
### files
|
||||
|
||||
**Permissions:** `files.read`, `files.write`
|
||||
|
||||
Store and retrieve files via the kernel ObjectStore (PVC or S3).
|
||||
All keys are scoped to `ext/{packageID}/` — extensions cannot access
|
||||
each other's files.
|
||||
|
||||
```python
|
||||
# Store a file with optional metadata
|
||||
files.put("reports/q1.pdf", pdf_bytes,
|
||||
content_type="application/pdf",
|
||||
metadata={"quarter": "Q1", "year": 2026})
|
||||
|
||||
# Read a file
|
||||
result = files.get("reports/q1.pdf")
|
||||
# result = {"content": b"...", "content_type": "application/pdf",
|
||||
# "size": 12345, "metadata": {"quarter": "Q1", "year": 2026}}
|
||||
|
||||
# Metadata only (no content transfer)
|
||||
meta = files.meta("reports/q1.pdf")
|
||||
|
||||
# List files by prefix
|
||||
entries = files.list(prefix="reports/", limit=50)
|
||||
# entries = [{"name": "reports/q1.pdf", "size": 12345, "content_type": "..."}]
|
||||
|
||||
# Check existence
|
||||
if files.exists("reports/q1.pdf"):
|
||||
files.delete("reports/q1.pdf")
|
||||
|
||||
# Bulk delete
|
||||
files.delete_prefix("temp/")
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `content` accepts string or bytes; `get()` returns bytes.
|
||||
- Maximum file size: 50 MB (configurable via `EXT_FILES_MAX_SIZE`).
|
||||
- Metadata is stored as a companion JSON object, not in the file itself.
|
||||
- `files.list()` automatically filters out internal metadata companions.
|
||||
|
||||
---
|
||||
|
||||
### workspace
|
||||
|
||||
**Permission:** `workspace.manage`
|
||||
|
||||
Managed disk directories for extensions that need a real filesystem
|
||||
(git clones, compilers, media tools). Each workspace is scoped to
|
||||
`{WORKSPACE_ROOT}/{packageID}/{name}/`.
|
||||
|
||||
```python
|
||||
# Create a workspace (idempotent)
|
||||
path = workspace.create("my-repo")
|
||||
# Returns the absolute path to the directory
|
||||
|
||||
# Get the path (None if workspace doesn't exist)
|
||||
path = workspace.path("my-repo")
|
||||
|
||||
# List all workspaces owned by this extension
|
||||
names = workspace.list() # ["my-repo", "cache"]
|
||||
|
||||
# Delete a workspace and all its contents
|
||||
workspace.delete("my-repo")
|
||||
|
||||
# Get disk usage in bytes (10-second timeout)
|
||||
size = workspace.usage("my-repo") # 1048576
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Names must match `^[a-z][a-z0-9_]{0,62}$` (lowercase, no spaces or separators).
|
||||
- Path traversal and symlink escape are blocked.
|
||||
- Quota enforcement via `WORKSPACE_QUOTA_MB` env var (0 = unlimited).
|
||||
- Module not available if `WORKSPACE_ROOT` is unset or not writable.
|
||||
Use `settings.has_capability("workspace")` to check availability.
|
||||
|
||||
---
|
||||
|
||||
## Example: automated stage hook
|
||||
|
||||
A simple hook that reads a setting, queries data, and advances:
|
||||
|
||||
```python
|
||||
def on_run(ctx):
|
||||
threshold = settings.get("approval_threshold", 1000)
|
||||
amount = ctx["stage_data"].get("amount", 0)
|
||||
|
||||
if amount > threshold:
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"High-value submission",
|
||||
body="Amount %d exceeds threshold." % amount,
|
||||
)
|
||||
return {"advance": True, "data": {"needs_review": True}}
|
||||
|
||||
return {"advance": True, "data": {"needs_review": False}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `forms` Module (v0.9.5)
|
||||
|
||||
**Permission:** `forms.validate`
|
||||
|
||||
Validates form data against a typed form template.
|
||||
|
||||
### `forms.validate(template, data)`
|
||||
|
||||
Validates `data` (a dict) against a `template` (a dict matching the TypedFormTemplate schema).
|
||||
|
||||
Returns a dict: `{"valid": True/False, "errors": [{"key": "...", "message": "..."}]}`.
|
||||
|
||||
```python
|
||||
result = forms.validate(
|
||||
{"fields": [{"key": "name", "type": "text", "label": "Name", "required": True}]},
|
||||
{"name": "Alice"},
|
||||
)
|
||||
# result["valid"] == True
|
||||
# result["errors"] == []
|
||||
```
|
||||
|
||||
Field types: `text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, `file`.
|
||||
|
||||
Supports: required checks, min/max length, pattern (regex), number range, date range, select option whitelist, conditional visibility (`condition.when`/`op`/`value`).
|
||||
@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
|
||||
},
|
||||
render(lang, code, container) {
|
||||
container.innerHTML =
|
||||
'<div style="padding:12px;background:var(--bg-2);' +
|
||||
'<div style="padding:12px;background:var(--bg-secondary);' +
|
||||
'border:1px solid var(--border);border-radius:8px">' +
|
||||
'<strong>Demo:</strong> ' + code +
|
||||
'</div>';
|
||||
@@ -74,7 +74,7 @@ The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
|
||||
takes a name and an options object: `type: 'block'` targets fenced code blocks,
|
||||
`match` checks the language tag, and `render` receives the language, raw code,
|
||||
and a container element. The `sw:ready` event fires once the SDK initializes;
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-secondary)`
|
||||
and `var(--border)` to follow the active theme.
|
||||
|
||||
## Step 4: Package It
|
||||
|
||||
193
docs/WORKFLOWS.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Workflows
|
||||
|
||||
Workflows are multi-stage processes with team assignment, validation gates,
|
||||
SLA enforcement, and optional Starlark automation. They are managed in
|
||||
**Team Admin > Workflows**.
|
||||
|
||||
## Core concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Definition** | A named template: stages, entry mode, staleness timeout. Created per-team or adopted from global definitions. |
|
||||
| **Stage** | One step in the workflow. Has a mode, audience, optional team assignment, and optional SLA. |
|
||||
| **Instance** | A running copy of a definition. Pins a published version snapshot and tracks accumulated stage data. |
|
||||
| **Assignment** | A queue entry linking an instance stage to a team member. Claim → work → complete. |
|
||||
| **Signoff** | An approval or rejection recorded against an instance stage (multi-party validation). |
|
||||
|
||||
## Entry modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `team_only` | Only authenticated team members can start instances. |
|
||||
| `public_link` | Anyone with the public URL can start an instance. The first stage must have `audience: public`. An `entry_token` is issued for the anonymous submitter to resume later. |
|
||||
|
||||
Public entry URL format:
|
||||
```
|
||||
{origin}/api/v1/public/workflows/{workflow_id}/start
|
||||
```
|
||||
|
||||
## Stage modes
|
||||
|
||||
Each stage has a **mode** that determines how it progresses:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `form` | User submits structured data. Stage data is accumulated into the instance. |
|
||||
| `review` | Multi-party sign-off gate. Requires configured approvals before advancing. |
|
||||
| `delegated` | Assigned to a team member queue. The assignee claims, works, and completes. |
|
||||
| `automated` | Starlark hook executes without user interaction. Can chain up to 10 consecutive automated stages. |
|
||||
|
||||
## Stage types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `simple` | Linear — always advances to the next ordinal. |
|
||||
| `dynamic` | Conditional — evaluates branch rules against stage data to pick the next stage. |
|
||||
| `automated` | Combined with mode `automated` for fully scripted stages. |
|
||||
|
||||
## Audiences
|
||||
|
||||
| Audience | Description |
|
||||
|----------|-------------|
|
||||
| `team` | Only authenticated team members can interact. |
|
||||
| `public` | Anonymous users can interact (used with `public_link` entry). |
|
||||
| `system` | System-generated stages, no direct user interaction. |
|
||||
|
||||
## Team assignment
|
||||
|
||||
When a stage has `assignment_team_id` set, the engine creates an
|
||||
**assignment** record:
|
||||
|
||||
1. Assignment enters the queue with status `unassigned`.
|
||||
2. A team member **claims** the assignment (status → `claimed`).
|
||||
3. The assignee works the stage and **completes** it (status → `completed`).
|
||||
4. The engine auto-advances to the next stage.
|
||||
|
||||
A **required role** can restrict who may claim:
|
||||
- Set `stage_config.required_role` to a team role name (e.g. `"reviewer"`).
|
||||
- Only members with that role can claim the assignment.
|
||||
|
||||
Team roles are configured in **Team Admin > Settings > Roles**.
|
||||
|
||||
## Signoff gates (multi-party validation)
|
||||
|
||||
Review-mode stages can require multiple approvals before advancing.
|
||||
Configure via `stage_config.validation`:
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"required_role": "approver",
|
||||
"reject_action": "cancel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `required_approvals` | Minimum approve decisions needed to advance. |
|
||||
| `required_role` | Only members with this team role can sign off. Empty = any member. |
|
||||
| `reject_action` | What happens on rejection: `"cancel"` (default) cancels the instance, or a stage name to reroute. |
|
||||
|
||||
Each signoff records: user, decision (`approve` or `reject`), optional comment, timestamp.
|
||||
|
||||
## SLA enforcement
|
||||
|
||||
Two timeout mechanisms run in a background scanner (every 5 minutes):
|
||||
|
||||
### Per-stage SLA
|
||||
|
||||
Set `sla_seconds` on a stage. When an instance has been in that stage
|
||||
longer than the threshold:
|
||||
|
||||
- `sla_breached` flag is set in instance metadata.
|
||||
- A `workflow.sla_breach` WebSocket event is emitted.
|
||||
- The instance is **not** auto-cancelled — breaches are informational.
|
||||
|
||||
### Per-workflow staleness
|
||||
|
||||
Set `staleness_timeout_hours` on the workflow definition. When an instance
|
||||
has not been updated for longer than the threshold:
|
||||
|
||||
- Instance status is set to `stale`.
|
||||
- All open assignments are cancelled.
|
||||
- A `workflow.stale` WebSocket event is emitted.
|
||||
|
||||
## Branch rules
|
||||
|
||||
Dynamic stages evaluate conditions against accumulated `stage_data`
|
||||
to determine the next stage. Rules are a JSON array on the stage:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "field": "priority", "op": "eq", "value": "high", "target_stage": "escalation" },
|
||||
{ "field": "amount", "op": "gt", "value": 10000, "target_stage": "manager-review" }
|
||||
]
|
||||
```
|
||||
|
||||
First matching rule wins. If no rules match, the next ordinal stage is used.
|
||||
|
||||
### Operators
|
||||
|
||||
| Op | Description |
|
||||
|----|-------------|
|
||||
| `eq` | Equal (string-normalized) |
|
||||
| `neq` | Not equal |
|
||||
| `gt`, `lt`, `gte`, `lte` | Numeric comparisons |
|
||||
| `exists` | Field is present in stage data |
|
||||
| `not_exists` | Field is absent |
|
||||
| `in` | Value is in a list |
|
||||
| `contains` | String contains substring |
|
||||
|
||||
`target_stage` can be a stage name (case-insensitive) or a numeric ordinal.
|
||||
|
||||
## Publishing
|
||||
|
||||
Workflows have a draft/publish lifecycle:
|
||||
|
||||
1. Edit stages and configuration in the workflow editor (draft state).
|
||||
2. **Publish** creates a versioned snapshot of all stages.
|
||||
3. New instances pin the latest published version.
|
||||
4. Editing stages after publishing does not affect running instances.
|
||||
|
||||
Version numbers auto-increment. The snapshot preserves the complete
|
||||
stage definition array at publish time.
|
||||
|
||||
## Starlark hooks
|
||||
|
||||
Automated stages execute a Starlark script via the `starlark_hook` field:
|
||||
|
||||
```
|
||||
package_id:entry_point
|
||||
```
|
||||
|
||||
For example: `my-automation:on_review` calls the `on_review` function
|
||||
in the `my-automation` package. If no entry point is specified,
|
||||
`on_run` is used.
|
||||
|
||||
The hook receives a context dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"instance_id": "...",
|
||||
"current_stage": "...",
|
||||
"workflow_id": "...",
|
||||
"started_by": "...",
|
||||
"stage_data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The hook returns a dict controlling what happens next:
|
||||
|
||||
| Key | Effect |
|
||||
|-----|--------|
|
||||
| `advance: True` | Auto-advance to the next stage |
|
||||
| `data: { ... }` | Merge into stage data for the next stage |
|
||||
| `error: "msg"` | Set instance status to `error` and halt |
|
||||
|
||||
Up to 10 consecutive automated stages can chain before the engine
|
||||
stops with an error (cycle guard).
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for available
|
||||
sandbox modules.
|
||||
BIN
icons/apple-touch-icon-b-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/apple-touch-icon-b-light.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/apple-touch-icon-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/apple-touch-icon-e-light.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/favicon-16-b-dark.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
icons/favicon-16-b-light.png
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
icons/favicon-16-e-dark.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
icons/favicon-16-e-light.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
icons/favicon-32-b-dark.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
icons/favicon-32-b-light.png
Normal file
|
After Width: | Height: | Size: 332 B |
BIN
icons/favicon-32-e-dark.png
Normal file
|
After Width: | Height: | Size: 457 B |
BIN
icons/favicon-32-e-light.png
Normal file
|
After Width: | Height: | Size: 459 B |
20
icons/favicon-animated.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes p { 0%,100%{r:2.5;opacity:1} 50%{r:3;opacity:0.8} }
|
||||
.c { animation: p 2.4s ease-in-out infinite; }
|
||||
</style>
|
||||
<rect width="32" height="32" rx="6" fill="#14142a"/>
|
||||
<line x1="16" y1="8" x2="26" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="26" y1="16" x2="16" y2="24" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="16" y1="24" x2="6" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="6" y1="16" x2="16" y2="8" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="6" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="26" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="16" y1="8" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="16" y1="16" x2="16" y2="24" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<circle cx="16" cy="8" r="2" fill="#3B82F6"/>
|
||||
<circle cx="6" cy="16" r="2" fill="#3B82F6"/>
|
||||
<circle cx="26" cy="16" r="2" fill="#EF4444"/>
|
||||
<circle cx="16" cy="24" r="2" fill="#3B82F6"/>
|
||||
<circle cx="16" cy="16" r="2.5" fill="#3B82F6" class="c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
16
icons/favicon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="6" fill="#14142a"/>
|
||||
<line x1="16" y1="7" x2="26" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="26" y1="16" x2="16" y2="25" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="16" y1="25" x2="6" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="6" y1="16" x2="16" y2="7" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
|
||||
<line x1="6" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="26" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="16" y1="7" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<line x1="16" y1="16" x2="16" y2="25" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
|
||||
<circle cx="16" cy="7" r="2" fill="#3B82F6"/>
|
||||
<circle cx="6" cy="16" r="2" fill="#3B82F6"/>
|
||||
<circle cx="26" cy="16" r="2" fill="#EF4444"/>
|
||||
<circle cx="16" cy="25" r="2" fill="#3B82F6"/>
|
||||
<circle cx="16" cy="16" r="2.5" fill="#3B82F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
icons/icon-128-b-dark.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
icons/icon-128-b-light.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
icons/icon-128-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/icon-128-e-light.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/icon-192-b-dark.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon-192-b-light.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
icons/icon-192-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/icon-192-e-light.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/icon-256-b-dark.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
icons/icon-256-b-light.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
icons/icon-256-e-dark.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
icons/icon-256-e-light.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
icons/icon-48-b-dark.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
icons/icon-48-b-light.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
icons/icon-48-e-dark.png
Normal file
|
After Width: | Height: | Size: 564 B |
BIN
icons/icon-48-e-light.png
Normal file
|
After Width: | Height: | Size: 559 B |
BIN
icons/icon-512-b-dark.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
icons/icon-512-b-light.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
icons/icon-512-e-dark.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
icons/icon-512-e-light.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
icons/icon-64-b-dark.png
Normal file
|
After Width: | Height: | Size: 626 B |
BIN
icons/icon-64-b-light.png
Normal file
|
After Width: | Height: | Size: 620 B |
BIN
icons/icon-64-e-dark.png
Normal file
|
After Width: | Height: | Size: 711 B |
BIN
icons/icon-64-e-light.png
Normal file
|
After Width: | Height: | Size: 707 B |
48
icons/icon-b-dark-animated.svg
Normal file
@@ -0,0 +1,48 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes center-pulse {
|
||||
0%, 100% { r: 10; opacity: 1; }
|
||||
50% { r: 12; opacity: 0.85; }
|
||||
}
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { r: 16; opacity: 0.12; }
|
||||
50% { r: 22; opacity: 0.06; }
|
||||
}
|
||||
@keyframes node-breathe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
|
||||
.center-glow { animation: glow-pulse 2.4s ease-in-out infinite; }
|
||||
.node-tl { animation: node-breathe 2.4s ease-in-out infinite 0.3s; }
|
||||
.node-tr { animation: node-breathe 2.4s ease-in-out infinite 0.6s; }
|
||||
.node-bl { animation: node-breathe 2.4s ease-in-out infinite 0.9s; }
|
||||
.node-br { animation: node-breathe 2.4s ease-in-out infinite 1.2s; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#14142a"/>
|
||||
|
||||
<!-- Diamond outline -->
|
||||
<line x1="100" y1="51" x2="145" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="145" y1="100" x2="100" y2="149" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="100" y1="149" x2="55" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="55" y1="100" x2="100" y2="51" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
|
||||
<!-- Cross lines -->
|
||||
<line x1="55" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="145" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="100" y1="51" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="100" y1="100" x2="100" y2="149" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
|
||||
<!-- Center glow -->
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12" class="center-glow"/>
|
||||
|
||||
<!-- Nodes -->
|
||||
<circle cx="100" cy="51" r="8" fill="#3B82F6" class="node-tl"/>
|
||||
<circle cx="55" cy="100" r="8" fill="#3B82F6" class="node-tr"/>
|
||||
<circle cx="145" cy="100" r="8" fill="#EF4444" class="node-bl"/>
|
||||
<circle cx="100" cy="149" r="8" fill="#3B82F6" class="node-br"/>
|
||||
|
||||
<!-- Center -->
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
21
icons/icon-b-dark.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="32" fill="#14142a"/>
|
||||
<!-- Diamond outline -->
|
||||
<line x1="100" y1="55" x2="142" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="142" y1="100" x2="100" y2="145" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="100" y1="145" x2="58" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<line x1="58" y1="100" x2="100" y2="55" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
|
||||
<!-- Cross lines -->
|
||||
<line x1="58" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="142" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="100" y1="55" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="100" y1="100" x2="100" y2="145" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<!-- Center glow -->
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12"/>
|
||||
<!-- Nodes -->
|
||||
<circle cx="100" cy="55" r="8" fill="#3B82F6"/>
|
||||
<circle cx="58" cy="100" r="8" fill="#3B82F6"/>
|
||||
<circle cx="142" cy="100" r="8" fill="#EF4444"/>
|
||||
<circle cx="100" cy="145" r="8" fill="#3B82F6"/>
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
43
icons/icon-b-light-animated.svg
Normal file
@@ -0,0 +1,43 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes center-pulse {
|
||||
0%, 100% { r: 10; opacity: 1; }
|
||||
50% { r: 12; opacity: 0.85; }
|
||||
}
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { r: 16; opacity: 0.1; }
|
||||
50% { r: 22; opacity: 0.05; }
|
||||
}
|
||||
@keyframes node-breathe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
|
||||
.center-glow { animation: glow-pulse 2.4s ease-in-out infinite; }
|
||||
.node-tl { animation: node-breathe 2.4s ease-in-out infinite 0.3s; }
|
||||
.node-tr { animation: node-breathe 2.4s ease-in-out infinite 0.6s; }
|
||||
.node-bl { animation: node-breathe 2.4s ease-in-out infinite 0.9s; }
|
||||
.node-br { animation: node-breathe 2.4s ease-in-out infinite 1.2s; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
|
||||
|
||||
<line x1="100" y1="51" x2="145" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="145" y1="100" x2="100" y2="149" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="100" y1="149" x2="55" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="55" y1="100" x2="100" y2="51" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
|
||||
<line x1="55" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="145" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="100" y1="51" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="100" y1="100" x2="100" y2="149" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1" class="center-glow"/>
|
||||
|
||||
<circle cx="100" cy="51" r="8" fill="#3B82F6" class="node-tl"/>
|
||||
<circle cx="55" cy="100" r="8" fill="#3B82F6" class="node-tr"/>
|
||||
<circle cx="145" cy="100" r="8" fill="#EF4444" class="node-bl"/>
|
||||
<circle cx="100" cy="149" r="8" fill="#3B82F6" class="node-br"/>
|
||||
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
17
icons/icon-b-light.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
|
||||
<line x1="100" y1="55" x2="142" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="142" y1="100" x2="100" y2="145" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="100" y1="145" x2="58" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="58" y1="100" x2="100" y2="55" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
||||
<line x1="58" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="142" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="100" y1="55" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="100" y1="100" x2="100" y2="145" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1"/>
|
||||
<circle cx="100" cy="55" r="8" fill="#3B82F6"/>
|
||||
<circle cx="58" cy="100" r="8" fill="#3B82F6"/>
|
||||
<circle cx="142" cy="100" r="8" fill="#EF4444"/>
|
||||
<circle cx="100" cy="145" r="8" fill="#3B82F6"/>
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
67
icons/icon-e-dark-animated.svg
Normal file
@@ -0,0 +1,67 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes draw-edge {
|
||||
from { stroke-dashoffset: 120; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes pop-node {
|
||||
0% { r: 0; opacity: 0; }
|
||||
70% { r: 8; }
|
||||
100% { r: 6; opacity: 1; }
|
||||
}
|
||||
@keyframes pop-center {
|
||||
0% { r: 0; opacity: 0; }
|
||||
70% { r: 10; }
|
||||
100% { r: 8; opacity: 0.7; }
|
||||
}
|
||||
@keyframes center-idle {
|
||||
0%, 100% { r: 8; opacity: 0.7; }
|
||||
50% { r: 9.5; opacity: 0.6; }
|
||||
}
|
||||
|
||||
.edge { stroke-dasharray: 120; stroke-dashoffset: 120; }
|
||||
.e-back-1 { animation: draw-edge 0.5s ease-out 0.0s forwards; }
|
||||
.e-back-2 { animation: draw-edge 0.5s ease-out 0.1s forwards; }
|
||||
.e-back-3 { animation: draw-edge 0.5s ease-out 0.2s forwards; }
|
||||
.e-mid-1 { animation: draw-edge 0.5s ease-out 0.35s forwards; }
|
||||
.e-mid-2 { animation: draw-edge 0.5s ease-out 0.45s forwards; }
|
||||
.e-mid-3 { animation: draw-edge 0.5s ease-out 0.55s forwards; }
|
||||
.e-frt-1 { animation: draw-edge 0.5s ease-out 0.7s forwards; }
|
||||
.e-frt-2 { animation: draw-edge 0.5s ease-out 0.8s forwards; }
|
||||
.e-frt-3 { animation: draw-edge 0.5s ease-out 0.9s forwards; }
|
||||
|
||||
.v-topback { animation: pop-node 0.3s ease-out 0.05s forwards; r: 0; opacity: 0; }
|
||||
.v-backleft { animation: pop-node 0.3s ease-out 0.15s forwards; r: 0; opacity: 0; }
|
||||
.v-backright { animation: pop-node 0.3s ease-out 0.25s forwards; r: 0; opacity: 0; }
|
||||
.v-center { animation: pop-center 0.3s ease-out 0.5s forwards, center-idle 2.4s ease-in-out 1s infinite; r: 0; opacity: 0; }
|
||||
.v-frontleft { animation: pop-node 0.3s ease-out 0.75s forwards; r: 0; opacity: 0; }
|
||||
.v-frontright { animation: pop-node 0.3s ease-out 0.85s forwards; r: 0; opacity: 0; }
|
||||
.v-bottom { animation: pop-node 0.3s ease-out 0.95s forwards; r: 0; opacity: 0; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#14142a"/>
|
||||
|
||||
<!-- Back edges -->
|
||||
<line x1="100" y1="42" x2="46" y2="72" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-1"/>
|
||||
<line x1="100" y1="42" x2="154" y2="72" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-2"/>
|
||||
<line x1="154" y1="72" x2="154" y2="128" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-3"/>
|
||||
|
||||
<!-- Mid edges -->
|
||||
<line x1="46" y1="72" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-1"/>
|
||||
<line x1="154" y1="72" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-2"/>
|
||||
<line x1="100" y1="100" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-3"/>
|
||||
|
||||
<!-- Front edges -->
|
||||
<line x1="46" y1="72" x2="46" y2="128" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-1"/>
|
||||
<line x1="46" y1="128" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-2"/>
|
||||
<line x1="154" y1="128" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-3"/>
|
||||
|
||||
<!-- Vertices -->
|
||||
<circle cx="100" cy="42" fill="#3B82F6" opacity="0.55" class="v-topback"/>
|
||||
<circle cx="46" cy="72" fill="#3B82F6" opacity="0.55" class="v-backleft"/>
|
||||
<circle cx="154" cy="72" fill="#EF4444" opacity="0.55" class="v-backright"/>
|
||||
<circle cx="100" cy="100" fill="#3B82F6" class="v-center"/>
|
||||
<circle cx="46" cy="128" fill="#EF4444" class="v-frontleft"/>
|
||||
<circle cx="154" cy="128" fill="#3B82F6" class="v-frontright"/>
|
||||
<circle cx="100" cy="158" fill="#3B82F6" class="v-bottom"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
23
icons/icon-e-dark.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="32" fill="#14142a"/>
|
||||
<!-- Back edges -->
|
||||
<line x1="100" y1="48" x2="150" y2="74" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
|
||||
<line x1="100" y1="48" x2="50" y2="74" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
|
||||
<line x1="150" y1="74" x2="150" y2="126" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
|
||||
<!-- Mid edges -->
|
||||
<line x1="50" y1="74" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="150" y1="74" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<line x1="100" y1="100" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<!-- Front edges -->
|
||||
<line x1="50" y1="74" x2="50" y2="126" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="50" y1="126" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="150" y1="126" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<!-- Vertices -->
|
||||
<circle cx="100" cy="48" r="6" fill="#3B82F6" opacity="0.55"/>
|
||||
<circle cx="50" cy="74" r="6" fill="#3B82F6" opacity="0.55"/>
|
||||
<circle cx="150" cy="74" r="6" fill="#EF4444" opacity="0.55"/>
|
||||
<circle cx="100" cy="100" r="8" fill="#3B82F6" opacity="0.7"/>
|
||||
<circle cx="50" cy="126" r="6" fill="#EF4444"/>
|
||||
<circle cx="150" cy="126" r="6" fill="#3B82F6"/>
|
||||
<circle cx="100" cy="152" r="6" fill="#3B82F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
63
icons/icon-e-light-animated.svg
Normal file
@@ -0,0 +1,63 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes draw-edge {
|
||||
from { stroke-dashoffset: 120; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes pop-node {
|
||||
0% { r: 0; opacity: 0; }
|
||||
70% { r: 8; }
|
||||
100% { r: 6; opacity: 1; }
|
||||
}
|
||||
@keyframes pop-center {
|
||||
0% { r: 0; opacity: 0; }
|
||||
70% { r: 10; }
|
||||
100% { r: 8; opacity: 0.7; }
|
||||
}
|
||||
@keyframes center-idle {
|
||||
0%, 100% { r: 8; opacity: 0.7; }
|
||||
50% { r: 9.5; opacity: 0.6; }
|
||||
}
|
||||
|
||||
.edge { stroke-dasharray: 120; stroke-dashoffset: 120; }
|
||||
.e-back-1 { animation: draw-edge 0.5s ease-out 0.0s forwards; }
|
||||
.e-back-2 { animation: draw-edge 0.5s ease-out 0.1s forwards; }
|
||||
.e-back-3 { animation: draw-edge 0.5s ease-out 0.2s forwards; }
|
||||
.e-mid-1 { animation: draw-edge 0.5s ease-out 0.35s forwards; }
|
||||
.e-mid-2 { animation: draw-edge 0.5s ease-out 0.45s forwards; }
|
||||
.e-mid-3 { animation: draw-edge 0.5s ease-out 0.55s forwards; }
|
||||
.e-frt-1 { animation: draw-edge 0.5s ease-out 0.7s forwards; }
|
||||
.e-frt-2 { animation: draw-edge 0.5s ease-out 0.8s forwards; }
|
||||
.e-frt-3 { animation: draw-edge 0.5s ease-out 0.9s forwards; }
|
||||
|
||||
.v-topback { animation: pop-node 0.3s ease-out 0.05s forwards; r: 0; opacity: 0; }
|
||||
.v-backleft { animation: pop-node 0.3s ease-out 0.15s forwards; r: 0; opacity: 0; }
|
||||
.v-backright { animation: pop-node 0.3s ease-out 0.25s forwards; r: 0; opacity: 0; }
|
||||
.v-center { animation: pop-center 0.3s ease-out 0.5s forwards, center-idle 2.4s ease-in-out 1s infinite; r: 0; opacity: 0; }
|
||||
.v-frontleft { animation: pop-node 0.3s ease-out 0.75s forwards; r: 0; opacity: 0; }
|
||||
.v-frontright { animation: pop-node 0.3s ease-out 0.85s forwards; r: 0; opacity: 0; }
|
||||
.v-bottom { animation: pop-node 0.3s ease-out 0.95s forwards; r: 0; opacity: 0; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
|
||||
|
||||
<line x1="100" y1="42" x2="46" y2="72" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-1"/>
|
||||
<line x1="100" y1="42" x2="154" y2="72" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-2"/>
|
||||
<line x1="154" y1="72" x2="154" y2="128" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-3"/>
|
||||
|
||||
<line x1="46" y1="72" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-1"/>
|
||||
<line x1="154" y1="72" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-2"/>
|
||||
<line x1="100" y1="100" x2="100" y2="158" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-3"/>
|
||||
|
||||
<line x1="46" y1="72" x2="46" y2="128" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-1"/>
|
||||
<line x1="46" y1="128" x2="100" y2="158" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-2"/>
|
||||
<line x1="154" y1="128" x2="100" y2="158" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-3"/>
|
||||
|
||||
<circle cx="100" cy="42" fill="#3B82F6" opacity="0.55" class="v-topback"/>
|
||||
<circle cx="46" cy="72" fill="#3B82F6" opacity="0.55" class="v-backleft"/>
|
||||
<circle cx="154" cy="72" fill="#EF4444" opacity="0.55" class="v-backright"/>
|
||||
<circle cx="100" cy="100" fill="#3B82F6" class="v-center"/>
|
||||
<circle cx="46" cy="128" fill="#EF4444" class="v-frontleft"/>
|
||||
<circle cx="154" cy="128" fill="#3B82F6" class="v-frontright"/>
|
||||
<circle cx="100" cy="158" fill="#3B82F6" class="v-bottom"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
19
icons/icon-e-light.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
|
||||
<line x1="100" y1="48" x2="150" y2="74" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
|
||||
<line x1="100" y1="48" x2="50" y2="74" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
|
||||
<line x1="150" y1="74" x2="150" y2="126" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
|
||||
<line x1="50" y1="74" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="150" y1="74" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="100" y1="100" x2="100" y2="152" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<line x1="50" y1="74" x2="50" y2="126" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="50" y1="126" x2="100" y2="152" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="150" y1="126" x2="100" y2="152" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<circle cx="100" cy="48" r="6" fill="#3B82F6" opacity="0.55"/>
|
||||
<circle cx="50" cy="74" r="6" fill="#3B82F6" opacity="0.55"/>
|
||||
<circle cx="150" cy="74" r="6" fill="#EF4444" opacity="0.55"/>
|
||||
<circle cx="100" cy="100" r="8" fill="#3B82F6" opacity="0.7"/>
|
||||
<circle cx="50" cy="126" r="6" fill="#EF4444"/>
|
||||
<circle cx="150" cy="126" r="6" fill="#3B82F6"/>
|
||||
<circle cx="100" cy="152" r="6" fill="#3B82F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
174
icons/icon-morph-dark-animated.svg
Normal file
@@ -0,0 +1,174 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes center-pulse {
|
||||
0%, 100% { r: 8; }
|
||||
50% { r: 9.5; }
|
||||
}
|
||||
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#14142a"/>
|
||||
|
||||
<!--
|
||||
B-state (flat diamond) positions:
|
||||
top: 100,51 left: 55,100 right: 145,100 bottom: 100,149 center: 100,100
|
||||
|
||||
E-state (wireframe cube) positions:
|
||||
topBack: 100,42 backLeft: 46,72 backRight: 154,72
|
||||
center: 100,100 frontLeft: 46,128 frontRight: 154,128 bottom: 100,158
|
||||
|
||||
Mapping B→E:
|
||||
top → topBack (100,51 → 100,42)
|
||||
left → backLeft (55,100 → 46,72) AND spawns frontLeft (55,100 → 46,128)
|
||||
right → backRight (145,100 → 154,72) AND spawns frontRight (145,100 → 154,128)
|
||||
bottom → bottom (100,149 → 100,158)
|
||||
center → center (100,100 → 100,100) stays
|
||||
|
||||
Timing: 1.5s hold B, 1s morph to E, 2s hold E, 1s morph back, repeat
|
||||
-->
|
||||
|
||||
<!-- ═══ EDGES ═══ -->
|
||||
<!-- Diamond outline (B) that morphs to cube edges (E) -->
|
||||
|
||||
<!-- top→right becomes topBack→backRight -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
|
||||
x1="100" y1="51" x2="145" y2="100">
|
||||
<animate attributeName="x1" values="100;100;100;100;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- right→bottom becomes backRight→frontRight -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
|
||||
x1="145" y1="100" x2="100" y2="149">
|
||||
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- bottom→left becomes frontRight→bottom (front bottom-right) -->
|
||||
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.25"
|
||||
x1="100" y1="149" x2="55" y2="100">
|
||||
<animate attributeName="x1" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="55;55;100;100;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;158;158;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.25;0.25;0.9;0.9;0.25" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- left→top becomes backLeft→topBack -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
|
||||
x1="55" y1="100" x2="100" y2="51">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="100;100;100;100;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Cross: left→center becomes backLeft→center -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
|
||||
x1="55" y1="100" x2="100" y2="100">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Cross: right→center becomes backRight→center -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
|
||||
x1="145" y1="100" x2="100" y2="100">
|
||||
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Cross: center→bottom stays vertical but bottom moves -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
|
||||
x1="100" y1="100" x2="100" y2="149">
|
||||
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Cross: top→center vertical -->
|
||||
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
|
||||
x1="100" y1="51" x2="100" y2="100">
|
||||
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- NEW edges that appear in E-state (start invisible) -->
|
||||
<!-- frontLeft→bottom -->
|
||||
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round"
|
||||
x1="55" y1="100" x2="100" y2="149" opacity="0">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;0.9;0.9;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- backLeft→frontLeft (left vertical) -->
|
||||
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round"
|
||||
x1="55" y1="100" x2="55" y2="100" opacity="0">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;0.9;0.9;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- ═══ NODES ═══ -->
|
||||
|
||||
<!-- Top → TopBack -->
|
||||
<circle cx="100" cy="51" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cy" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Left → BackLeft -->
|
||||
<circle cx="55" cy="100" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Right → BackRight -->
|
||||
<circle cx="145" cy="100" r="8" fill="#EF4444">
|
||||
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Bottom → Bottom (slight shift) -->
|
||||
<circle cx="100" cy="149" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cy" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- FrontLeft: spawns from Left position, invisible in B -->
|
||||
<circle cx="55" cy="100" r="0" fill="#EF4444" opacity="0">
|
||||
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- FrontRight: spawns from Right position, invisible in B -->
|
||||
<circle cx="145" cy="100" r="0" fill="#3B82F6" opacity="0">
|
||||
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Center (always visible, pulse) -->
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node">
|
||||
<animate attributeName="r" values="10;10;8;8;10" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.7;0.7;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Center glow -->
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12">
|
||||
<animate attributeName="r" values="16;16;12;12;16" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.12;0.12;0.06;0.06;0.12" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
127
icons/icon-morph-light-animated.svg
Normal file
@@ -0,0 +1,127 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@keyframes center-pulse {
|
||||
0%, 100% { r: 8; }
|
||||
50% { r: 9.5; }
|
||||
}
|
||||
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
|
||||
</style>
|
||||
|
||||
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
|
||||
|
||||
<!-- Diamond/cube edges -->
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
|
||||
x1="100" y1="51" x2="145" y2="100">
|
||||
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
|
||||
x1="145" y1="100" x2="100" y2="149">
|
||||
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<line stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.18"
|
||||
x1="100" y1="149" x2="55" y2="100">
|
||||
<animate attributeName="x1" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="55;55;100;100;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;158;158;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.18;0.18;0.7;0.7;0.18" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
|
||||
x1="55" y1="100" x2="100" y2="51">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Cross lines -->
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
|
||||
x1="55" y1="100" x2="100" y2="100">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
|
||||
x1="145" y1="100" x2="100" y2="100">
|
||||
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
|
||||
x1="100" y1="100" x2="100" y2="149">
|
||||
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
|
||||
x1="100" y1="51" x2="100" y2="100">
|
||||
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- E-only edges (spawn) -->
|
||||
<line stroke="#333" stroke-width="2.5" stroke-linecap="round"
|
||||
x1="55" y1="100" x2="100" y2="149" opacity="0">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;0.7;0.7;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<line stroke="#333" stroke-width="2.5" stroke-linecap="round"
|
||||
x1="55" y1="100" x2="55" y2="100" opacity="0">
|
||||
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;0.7;0.7;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Nodes -->
|
||||
<circle cx="100" cy="51" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cy" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="55" cy="100" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="145" cy="100" r="8" fill="#EF4444">
|
||||
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="100" cy="149" r="8" fill="#3B82F6">
|
||||
<animate attributeName="cy" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Spawn nodes -->
|
||||
<circle cx="55" cy="100" r="0" fill="#EF4444" opacity="0">
|
||||
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="145" cy="100" r="0" fill="#3B82F6" opacity="0">
|
||||
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Center -->
|
||||
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node">
|
||||
<animate attributeName="r" values="10;10;8;8;10" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;1;0.7;0.7;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1">
|
||||
<animate attributeName="r" values="16;16;12;12;16" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.1;0.1;0.05;0.05;0.1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
BIN
icons/preview-sheet.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
12
icons/wordmark-dark.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 520 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- A-frame lettermark -->
|
||||
<line x1="28" y1="8" x2="6" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line x1="28" y1="8" x2="50" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line x1="14" y1="46" x2="42" y2="46" stroke="#3B82F6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="28" cy="8" r="4" fill="#3B82F6"/>
|
||||
<circle cx="14" cy="46" r="2.5" fill="#EF4444"/>
|
||||
<circle cx="28" cy="46" r="2.5" fill="#3B82F6"/>
|
||||
<circle cx="42" cy="46" r="2.5" fill="#EF4444"/>
|
||||
<!-- "rmature" text -->
|
||||
<text x="58" y="62" fill="#dddddd" font-family="-apple-system, system-ui, 'Segoe UI', sans-serif" font-size="52" font-weight="300" letter-spacing="1">rmature</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
10
icons/wordmark-light.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 520 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="28" y1="8" x2="6" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line x1="28" y1="8" x2="50" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line x1="14" y1="46" x2="42" y2="46" stroke="#3B82F6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="28" cy="8" r="4" fill="#3B82F6"/>
|
||||
<circle cx="14" cy="46" r="2.5" fill="#EF4444"/>
|
||||
<circle cx="28" cy="46" r="2.5" fill="#3B82F6"/>
|
||||
<circle cx="42" cy="46" r="2.5" fill="#EF4444"/>
|
||||
<text x="58" y="62" fill="#222222" font-family="-apple-system, system-ui, 'Segoe UI', sans-serif" font-size="52" font-weight="300" letter-spacing="1">rmature</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -19,7 +19,6 @@
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "public",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -48,7 +47,6 @@
|
||||
"ordinal": 1,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -71,7 +69,6 @@
|
||||
"ordinal": 2,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"sla_seconds": 3600,
|
||||
"form_template": {
|
||||
@@ -91,7 +88,6 @@
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -108,9 +104,8 @@
|
||||
{
|
||||
"name": "verify",
|
||||
"ordinal": 4,
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
|
||||
@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
||||
cid = conv["id"]
|
||||
|
||||
# Add creator as admin participant
|
||||
# NOTE: display_name is a snapshot captured at creation time.
|
||||
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
|
||||
# Column retained for backward compatibility; UI no longer relies on this value for display.
|
||||
if creator_id:
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(p.get("type", "user")),
|
||||
"display_name": _str(p.get("display_name", "")),
|
||||
"display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
|
||||
"role": _str(p.get("role", "member")),
|
||||
"joined_at": "",
|
||||
})
|
||||
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
|
||||
"role": _str(role),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
59
packages/chat-runner/js/conversations.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Chat Runner — chat/conversations suite
|
||||
*
|
||||
* Tests conversation CRUD via the chat-core ext API.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var api = window.CR.api;
|
||||
|
||||
sw.testing.suite('chat/conversations', async function (s) {
|
||||
var convId;
|
||||
|
||||
s.test('create conversation', async function (t) {
|
||||
var r = await api.post('/conversations', {
|
||||
title: 'Runner Test Convo ' + Date.now(),
|
||||
type: 'direct'
|
||||
});
|
||||
t.assert.ok(r.id, 'conversation has id');
|
||||
t.assert.ok(r.title, 'conversation has title');
|
||||
convId = r.id;
|
||||
s.track('conversation', convId);
|
||||
});
|
||||
|
||||
s.test('get conversation', async function (t) {
|
||||
t.assert.ok(convId, 'convId from previous test');
|
||||
var r = await api.get('/conversations/' + convId);
|
||||
t.assert.eq(r.id, convId, 'id matches');
|
||||
});
|
||||
|
||||
s.test('update conversation', async function (t) {
|
||||
t.assert.ok(convId, 'convId from previous test');
|
||||
var r = await api.put('/conversations/' + convId, {
|
||||
title: 'Updated Convo Title'
|
||||
});
|
||||
t.assert.eq(r.title, 'Updated Convo Title', 'title updated');
|
||||
});
|
||||
|
||||
s.test('list conversations', async function (t) {
|
||||
var r = await api.get('/conversations');
|
||||
var list = Array.isArray(r) ? r : (r.data || []);
|
||||
t.assert.ok(Array.isArray(list), 'response is array');
|
||||
var found = list.some(function (c) { return c.id === convId; });
|
||||
t.assert.ok(found, 'created conversation in list');
|
||||
});
|
||||
|
||||
s.test('delete conversation', async function (t) {
|
||||
t.assert.ok(convId, 'convId from previous test');
|
||||
await api.del('/conversations/' + convId);
|
||||
try {
|
||||
await api.get('/conversations/' + convId);
|
||||
t.assert.ok(false, 'expected error after delete');
|
||||
} catch (e) {
|
||||
t.assert.ok(true, 'conversation not found after delete');
|
||||
}
|
||||
convId = null;
|
||||
});
|
||||
});
|
||||
})();
|
||||
64
packages/chat-runner/js/main.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Chat Runner — Entry Point
|
||||
*
|
||||
* Boot SDK, load test modules in dependency order.
|
||||
* Each module is an IIFE that registers suites via sw.testing.suite().
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
console.warn('[ChatRunner] SDK boot failed:', e.message);
|
||||
}
|
||||
|
||||
window.CR = {
|
||||
base: window.__BASE__ || '',
|
||||
api: sw.api.ext('chat-core')
|
||||
};
|
||||
|
||||
var surfaceId = 'chat-runner';
|
||||
var assetBase = '/surfaces/' + surfaceId + '/js/';
|
||||
if (window.CR.base) assetBase = window.CR.base + assetBase;
|
||||
|
||||
var modules = [
|
||||
'conversations.js',
|
||||
'messaging.js',
|
||||
'shell-topbar.js'
|
||||
];
|
||||
|
||||
var loaded = 0;
|
||||
function loadNext() {
|
||||
if (loaded >= modules.length) { onReady(); 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('[ChatRunner] Failed to load: ' + modules[loaded]);
|
||||
loaded++; loadNext();
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
console.log('[ChatRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
|
||||
var manifest = window.__MANIFEST__ || {};
|
||||
if (manifest.id === surfaceId) {
|
||||
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
|
||||
}
|
||||
}
|
||||
|
||||
loadNext();
|
||||
})();
|
||||
60
packages/chat-runner/js/messaging.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Chat Runner — chat/messaging suite
|
||||
*
|
||||
* Tests message CRUD and search within conversations.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var api = window.CR.api;
|
||||
|
||||
sw.testing.suite('chat/messaging', async function (s) {
|
||||
var convId, msgId;
|
||||
|
||||
s.beforeAll(async function () {
|
||||
var r = await api.post('/conversations', {
|
||||
title: 'Messaging Test ' + Date.now(),
|
||||
type: 'direct'
|
||||
});
|
||||
convId = r.id;
|
||||
s.track('conversation', convId);
|
||||
});
|
||||
|
||||
s.test('send message', async function (t) {
|
||||
var r = await api.post('/messages/' + convId, {
|
||||
content: 'Hello from chat-runner test!',
|
||||
content_type: 'text'
|
||||
});
|
||||
t.assert.ok(r.id, 'message has id');
|
||||
t.assert.ok(r.content, 'message has content');
|
||||
msgId = r.id;
|
||||
});
|
||||
|
||||
s.test('list messages', async function (t) {
|
||||
var r = await api.get('/messages/' + convId);
|
||||
var list = Array.isArray(r) ? r : (r.data || r.messages || []);
|
||||
t.assert.ok(Array.isArray(list), 'messages is array');
|
||||
t.assert.ok(list.length > 0, 'at least one message');
|
||||
var found = list.some(function (m) { return m.id === msgId; });
|
||||
t.assert.ok(found, 'sent message appears in list');
|
||||
});
|
||||
|
||||
s.test('search conversations', async function (t) {
|
||||
var r = await api.get('/search?q=chat-runner');
|
||||
var list = Array.isArray(r) ? r : (r.data || r.conversations || r.results || []);
|
||||
t.assert.ok(Array.isArray(list), 'search returns array');
|
||||
if (list.length === 0) {
|
||||
t.warn('Search returned empty — may need time for indexing');
|
||||
}
|
||||
});
|
||||
|
||||
s.test('mark read', async function (t) {
|
||||
try {
|
||||
await api.post('/read/' + convId, {});
|
||||
t.assert.ok(true, 'mark read succeeded');
|
||||
} catch (e) {
|
||||
t.warn('mark read failed: ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
31
packages/chat-runner/js/shell-topbar.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Chat Runner — chat/shell-topbar suite
|
||||
*
|
||||
* Validates the Chat surface uses the v0.7.0 shell topbar contract
|
||||
* and does not render the legacy sw.shell.Topbar component.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
sw.testing.suite('chat/shell-topbar', async function (s) {
|
||||
|
||||
s.test('surface JS does not reference legacy Topbar', async function (t) {
|
||||
var base = window.__BASE__ || '';
|
||||
var resp = await fetch(base + '/surfaces/chat/js/main.js');
|
||||
t.assert.eq(resp.status, 200, 'fetched chat main.js');
|
||||
var src = await resp.text();
|
||||
var hasLegacy = src.indexOf('sw.shell.Topbar') !== -1;
|
||||
t.assert.ok(!hasLegacy, 'no sw.shell.Topbar reference (uses shell topbar API)');
|
||||
});
|
||||
|
||||
s.test('surface JS uses shell topbar API', async function (t) {
|
||||
var base = window.__BASE__ || '';
|
||||
var resp = await fetch(base + '/surfaces/chat/js/main.js');
|
||||
var src = await resp.text();
|
||||
var usesAPI = src.indexOf('sw.shell.topbar.setTitle') !== -1
|
||||
|| src.indexOf('sw.shell.topbar.setSlot') !== -1;
|
||||
t.assert.ok(usesAPI, 'uses sw.shell.topbar.setTitle or setSlot');
|
||||
});
|
||||
|
||||
});
|
||||
})();
|
||||
9
packages/chat-runner/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "chat-runner",
|
||||
"icon": "💬",
|
||||
"type": "test-runner",
|
||||
"title": "Chat Runner",
|
||||
"auth": "admin",
|
||||
"version": "0.2.0",
|
||||
"description": "Integration tests for Chat package — conversations, messaging, search."
|
||||
}
|
||||
@@ -9,29 +9,29 @@
|
||||
|
||||
/* ── Layout ─────────────────────────────── */
|
||||
|
||||
.chat-app {
|
||||
.ext-chat-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
.ext-chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
.ext-chat-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
.ext-chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
@@ -40,16 +40,16 @@
|
||||
|
||||
/* ── Topbar extras ──────────────────────── */
|
||||
|
||||
.chat-topbar__thread-title {
|
||||
.ext-chat-topbar__thread-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
margin-right: var(--sp-2);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────── */
|
||||
|
||||
.chat-sidebar {
|
||||
.ext-chat-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
@@ -58,76 +58,76 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-sidebar__header {
|
||||
.ext-chat-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-sidebar__title {
|
||||
.ext-chat-sidebar__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-sidebar__list {
|
||||
.ext-chat-sidebar__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-sidebar__empty {
|
||||
padding: 24px 16px;
|
||||
.ext-chat-sidebar__empty {
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item {
|
||||
padding: 10px 16px;
|
||||
.ext-chat-sidebar__item {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-sidebar__item:hover {
|
||||
.ext-chat-sidebar__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-sidebar__item--active {
|
||||
.ext-chat-sidebar__item--active {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.chat-sidebar__item-top {
|
||||
.ext-chat-sidebar__item-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-title {
|
||||
.ext-chat-sidebar__item-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
margin-right: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-sidebar__item-time {
|
||||
.ext-chat-sidebar__item-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-bottom {
|
||||
.ext-chat-sidebar__item-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-sidebar__item-preview {
|
||||
.ext-chat-sidebar__item-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
@@ -136,7 +136,7 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-sidebar__badge {
|
||||
.ext-chat-sidebar__badge {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
font-size: 11px;
|
||||
@@ -153,17 +153,17 @@
|
||||
|
||||
/* ── Sidebar Search ────────────────────── */
|
||||
|
||||
.chat-sidebar__search {
|
||||
.ext-chat-sidebar__search {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input {
|
||||
.ext-chat-sidebar__search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-2) var(--sp-3);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
@@ -171,12 +171,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input:focus {
|
||||
.ext-chat-sidebar__search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear {
|
||||
.ext-chat-sidebar__search-clear {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
top: 50%;
|
||||
@@ -186,21 +186,21 @@
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
padding: 0 var(--sp-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear:hover {
|
||||
.ext-chat-sidebar__search-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-results {
|
||||
.ext-chat-sidebar__search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-section {
|
||||
padding: 8px 16px 4px;
|
||||
.ext-chat-sidebar__search-section {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -208,13 +208,13 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-loading {
|
||||
.ext-chat-sidebar__search-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.chat-sidebar__item--search-msg .chat-sidebar__item-preview {
|
||||
.ext-chat-sidebar__item--search-msg .ext-chat-sidebar__item-preview {
|
||||
font-size: 13px;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
@@ -225,58 +225,58 @@
|
||||
|
||||
/* ── Message Thread ─────────────────────── */
|
||||
|
||||
.chat-thread {
|
||||
.ext-chat-thread {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-thread--empty {
|
||||
.ext-chat-thread--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-thread__messages {
|
||||
.ext-chat-thread__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding: var(--sp-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.chat-thread__loading {
|
||||
.ext-chat-thread__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.chat-thread__loading-more {
|
||||
.ext-chat-thread__loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-thread__load-more {
|
||||
.ext-chat-thread__load-more {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-thread__load-more:hover {
|
||||
.ext-chat-thread__load-more:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-thread__typing {
|
||||
padding: 4px 16px 8px;
|
||||
.ext-chat-thread__typing {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
@@ -284,52 +284,52 @@
|
||||
|
||||
/* ── Message Bubble ─────────────────────── */
|
||||
|
||||
.chat-msg {
|
||||
.ext-chat-msg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-msg--own {
|
||||
.ext-chat-msg--own {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-msg--system {
|
||||
.ext-chat-msg--system {
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-msg--system span {
|
||||
.ext-chat-msg--system span {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-msg--deleted {
|
||||
.ext-chat-msg--deleted {
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-msg--deleted em {
|
||||
.ext-chat-msg--deleted em {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-msg__body {
|
||||
.ext-chat-msg__body {
|
||||
max-width: 65%;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__body {
|
||||
.ext-chat-msg--own .ext-chat-msg__body {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.chat-msg__name {
|
||||
.ext-chat-msg__name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
@@ -337,42 +337,42 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__content {
|
||||
.ext-chat-msg__content {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg__meta {
|
||||
.ext-chat-msg__meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__time {
|
||||
.ext-chat-msg__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__time {
|
||||
.ext-chat-msg--own .ext-chat-msg__time {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.chat-msg__edited {
|
||||
.ext-chat-msg__edited {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__edited {
|
||||
.ext-chat-msg--own .ext-chat-msg__edited {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* ── Message Actions ────────────────────── */
|
||||
|
||||
.chat-msg__actions {
|
||||
.ext-chat-msg__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
position: absolute;
|
||||
@@ -380,50 +380,50 @@
|
||||
right: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__actions {
|
||||
.ext-chat-msg--own .ext-chat-msg__actions {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.chat-msg__action {
|
||||
.ext-chat-msg__action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.chat-msg__action:hover {
|
||||
.ext-chat-msg__action:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-msg__action--danger:hover {
|
||||
.ext-chat-msg__action--danger:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── Message Edit ───────────────────────── */
|
||||
|
||||
.chat-msg__edit {
|
||||
.ext-chat-msg__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-msg__edit-input {
|
||||
.ext-chat-msg__edit-input {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
@@ -431,28 +431,28 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-msg__edit-actions {
|
||||
.ext-chat-msg__edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Compose Bar ────────────────────────── */
|
||||
|
||||
.chat-compose {
|
||||
.ext-chat-compose {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-compose__input {
|
||||
.ext-chat-compose__input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
@@ -462,7 +462,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-compose__input:focus {
|
||||
.ext-chat-compose__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||
@@ -470,7 +470,7 @@
|
||||
|
||||
/* ── Participant Sidebar ────────────────── */
|
||||
|
||||
.chat-participants {
|
||||
.ext-chat-participants {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
border-left: 1px solid var(--border);
|
||||
@@ -479,30 +479,30 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-participants__header {
|
||||
.ext-chat-participants__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-participants__list {
|
||||
.ext-chat-participants__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.chat-participants__item {
|
||||
.ext-chat-participants__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
}
|
||||
|
||||
.chat-participants__name {
|
||||
.ext-chat-participants__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
@@ -510,14 +510,14 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-participants__badge {
|
||||
.ext-chat-participants__badge {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
margin-left: var(--sp-1);
|
||||
}
|
||||
|
||||
.chat-participants__status {
|
||||
.ext-chat-participants__status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
@@ -525,75 +525,75 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-participants__status--online {
|
||||
.ext-chat-participants__status--online {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.chat-participants__remove {
|
||||
.ext-chat-participants__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
padding: 0 var(--sp-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-participants__remove:hover {
|
||||
.ext-chat-participants__remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── New Conversation Dialog ────────────── */
|
||||
|
||||
.chat-new {
|
||||
.ext-chat-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--sp-3);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.chat-new__type {
|
||||
.ext-chat-new__type {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.chat-new__type label {
|
||||
.ext-chat-new__type label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-new__title {
|
||||
.ext-chat-new__title {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-new__selected {
|
||||
.ext-chat-new__selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.chat-new__chip {
|
||||
.ext-chat-new__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--sp-1);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 3px var(--sp-2);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.chat-new__chip button {
|
||||
.ext-chat-new__chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
@@ -607,10 +607,10 @@
|
||||
/* Allow the autocomplete dropdown to overflow the dialog body.
|
||||
Applies to both New Conversation and Add Participant dialogs. */
|
||||
|
||||
.sw-dialog__body:has(.sw-user-picker) {
|
||||
[data-ext="chat"] .sw-dialog__body:has(.sw-user-picker) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sw-dialog:has(.sw-user-picker) {
|
||||
[data-ext="chat"] .sw-dialog:has(.sw-user-picker) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Chat — Surface Entry Point (v0.2.0)
|
||||
* Chat — Surface Entry Point (v0.3.0)
|
||||
*
|
||||
* Messaging surface built on chat-core library:
|
||||
* sw.api.ext('chat-core') — conversation/message CRUD
|
||||
* sw.api.ext('chat') — typing indicators
|
||||
* sw.realtime — live events
|
||||
* sw.ui.* — primitive components
|
||||
* sw.shell.Topbar — navigation bar
|
||||
* sw.shell.topbar — shell topbar API
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
@@ -42,7 +42,6 @@
|
||||
var api = sw.api.ext('chat-core');
|
||||
var chatApi = sw.api.ext('chat');
|
||||
var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui;
|
||||
var Topbar = sw.shell.Topbar;
|
||||
|
||||
// Import UserPicker directly (not in sw.ui index)
|
||||
var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver);
|
||||
@@ -123,60 +122,60 @@
|
||||
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
||||
|
||||
return html`
|
||||
<div class="chat-sidebar">
|
||||
<div class="chat-sidebar__header">
|
||||
<span class="chat-sidebar__title">Conversations</span>
|
||||
<div class="ext-chat-sidebar">
|
||||
<div class="ext-chat-sidebar__header">
|
||||
<span class="ext-chat-sidebar__title">Conversations</span>
|
||||
<${Button} size="sm" onClick=${onNew}>New<//>
|
||||
</div>
|
||||
<div class="chat-sidebar__search">
|
||||
<input class="chat-sidebar__search-input"
|
||||
<div class="ext-chat-sidebar__search">
|
||||
<input class="ext-chat-sidebar__search-input"
|
||||
type="text"
|
||||
value=${searchQuery}
|
||||
placeholder="Search\u2026"
|
||||
onInput=${handleSearchInput} />
|
||||
${searchQuery && html`
|
||||
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
||||
<button class="ext-chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
||||
</div>
|
||||
${showSearch ? html`
|
||||
<div class="chat-sidebar__search-results">
|
||||
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
||||
<div class="ext-chat-sidebar__search-results">
|
||||
${searching && html`<div class="ext-chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
||||
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
||||
<div class="chat-sidebar__empty">No results</div>`}
|
||||
<div class="ext-chat-sidebar__empty">No results</div>`}
|
||||
${sConvs.length > 0 && html`
|
||||
<div class="chat-sidebar__search-section">Conversations</div>
|
||||
<div class="ext-chat-sidebar__search-section">Conversations</div>
|
||||
${sConvs.map(c => html`
|
||||
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
<div key=${c.id} class="ext-chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
||||
<div class="ext-chat-sidebar__item-top">
|
||||
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
</div>
|
||||
</div>`)}`}
|
||||
${sMsgs.length > 0 && html`
|
||||
<div class="chat-sidebar__search-section">Messages</div>
|
||||
<div class="ext-chat-sidebar__search-section">Messages</div>
|
||||
${sMsgs.map(m => html`
|
||||
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
||||
<div key=${m.id} class="ext-chat-sidebar__item ext-chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
||||
<div class="ext-chat-sidebar__item-top">
|
||||
<span class="ext-chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
||||
</div>
|
||||
<div class="chat-sidebar__item-bottom">
|
||||
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
||||
<div class="ext-chat-sidebar__item-bottom">
|
||||
<span class="ext-chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
||||
</div>
|
||||
</div>`)}`}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="chat-sidebar__list">
|
||||
<div class="ext-chat-sidebar__list">
|
||||
${conversations.length === 0 && html`
|
||||
<div class="chat-sidebar__empty">No conversations yet</div>`}
|
||||
<div class="ext-chat-sidebar__empty">No conversations yet</div>`}
|
||||
${conversations.map(c => html`
|
||||
<div key=${c.id}
|
||||
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
|
||||
class=${'ext-chat-sidebar__item' + (selected === c.id ? ' ext-chat-sidebar__item--active' : '')}
|
||||
onClick=${() => onSelect(c.id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
<div class="ext-chat-sidebar__item-top">
|
||||
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
</div>
|
||||
<div class="chat-sidebar__item-bottom">
|
||||
<span class="chat-sidebar__item-preview">
|
||||
<div class="ext-chat-sidebar__item-bottom">
|
||||
<span class="ext-chat-sidebar__item-preview">
|
||||
${c.last_message
|
||||
? truncate(c.last_message.content_type === 'system'
|
||||
? '\u2022 ' + c.last_message.content
|
||||
@@ -184,7 +183,7 @@
|
||||
: 'No messages yet'}
|
||||
</span>
|
||||
${(unread[c.id] || 0) > 0 && html`
|
||||
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
|
||||
<span class="ext-chat-sidebar__badge">${unread[c.id]}</span>`}
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>
|
||||
@@ -204,14 +203,14 @@
|
||||
|
||||
if (msg._deleted) {
|
||||
return html`
|
||||
<div class="chat-msg chat-msg--deleted">
|
||||
<div class="ext-chat-msg ext-chat-msg--deleted">
|
||||
<em>This message was deleted</em>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (msg.content_type === 'system') {
|
||||
return html`
|
||||
<div class="chat-msg chat-msg--system">
|
||||
<div class="ext-chat-msg ext-chat-msg--system">
|
||||
<span>${msg.content}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -239,37 +238,37 @@
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
|
||||
<div class=${'ext-chat-msg' + (isOwn ? ' ext-chat-msg--own' : '')}
|
||||
onMouseEnter=${() => setHover(true)}
|
||||
onMouseLeave=${() => setHover(false)}>
|
||||
${!isOwn && html`
|
||||
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
|
||||
<div class="chat-msg__body">
|
||||
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
|
||||
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
|
||||
<div class="ext-chat-msg__body">
|
||||
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
|
||||
${editing ? html`
|
||||
<div class="chat-msg__edit">
|
||||
<textarea class="chat-msg__edit-input"
|
||||
<div class="ext-chat-msg__edit">
|
||||
<textarea class="ext-chat-msg__edit-input"
|
||||
value=${editText}
|
||||
onInput=${e => setEditText(e.target.value)}
|
||||
onKeyDown=${onEditKeyDown}
|
||||
rows="2" />
|
||||
<div class="chat-msg__edit-actions">
|
||||
<div class="ext-chat-msg__edit-actions">
|
||||
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
||||
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
||||
</div>
|
||||
</div>
|
||||
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
||||
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||
<div class="chat-msg__content">${msg.content}</div>`}
|
||||
<div class="chat-msg__meta">
|
||||
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
|
||||
<div class="ext-chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||
<div class="ext-chat-msg__content">${msg.content}</div>`}
|
||||
<div class="ext-chat-msg__meta">
|
||||
<span class="ext-chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||
${msg.edited_at && html`<span class="ext-chat-msg__edited">(edited)</span>`}
|
||||
</div>
|
||||
</div>
|
||||
${hover && isOwn && !editing && html`
|
||||
<div class="chat-msg__actions">
|
||||
<button class="chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||
<div class="ext-chat-msg__actions">
|
||||
<button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||
<button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -285,20 +284,32 @@
|
||||
var [hasMore, setHasMore] = useState(false);
|
||||
var [nextCursor, setNextCursor] = useState('');
|
||||
var [typingUsers, setTypingUsers] = useState({});
|
||||
var [resolvedNames, setResolvedNames] = useState({});
|
||||
var bottomRef = useRef(null);
|
||||
var listRef = useRef(null);
|
||||
var userId = currentUserId();
|
||||
|
||||
// Build participant lookup
|
||||
// Resolve participant display names from users table (not snapshot)
|
||||
useEffect(() => {
|
||||
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
sw.users.resolveMany(ids).then(map => {
|
||||
var names = {};
|
||||
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||
setResolvedNames(names);
|
||||
});
|
||||
}, [participants]);
|
||||
|
||||
// Build participant lookup from resolved names
|
||||
var partMap = useMemo(() => {
|
||||
var m = {};
|
||||
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
|
||||
(participants || []).forEach(p => { m[p.participant_id] = resolvedNames[p.participant_id] || p.display_name || 'Unknown'; });
|
||||
return m;
|
||||
}, [participants]);
|
||||
}, [participants, resolvedNames]);
|
||||
|
||||
// Enrich messages with display names
|
||||
function enrichMessages(msgs) {
|
||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
|
||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || 'Unknown' }));
|
||||
}
|
||||
|
||||
// Load initial messages
|
||||
@@ -361,7 +372,7 @@
|
||||
|
||||
var unsubs = [
|
||||
sw.realtime.subscribe(channel, 'message', (payload) => {
|
||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
|
||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || 'Unknown' };
|
||||
setMessages(prev => [...prev, msg]);
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
// Auto mark read if from someone else
|
||||
@@ -386,7 +397,8 @@
|
||||
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
||||
var pid = payload.participant_id;
|
||||
if (pid === userId) return;
|
||||
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
|
||||
var typingName = resolvedNames[pid] || payload.display_name || 'Someone';
|
||||
setTypingUsers(prev => ({ ...prev, [pid]: typingName }));
|
||||
clearTimeout(typingTimers[pid]);
|
||||
typingTimers[pid] = setTimeout(() => {
|
||||
setTypingUsers(prev => {
|
||||
@@ -442,18 +454,18 @@
|
||||
|
||||
if (!conversationId) {
|
||||
return html`
|
||||
<div class="chat-thread chat-thread--empty">
|
||||
<div class="ext-chat-thread chat-thread--empty">
|
||||
<p>Select a conversation or start a new one</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-thread">
|
||||
<div class="chat-thread__messages" ref=${listRef}>
|
||||
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
|
||||
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
||||
<div class="ext-chat-thread">
|
||||
<div class="ext-chat-thread__messages" ref=${listRef}>
|
||||
${loading && messages.length === 0 && html`<div class="ext-chat-thread__loading"><${Spinner} /></div>`}
|
||||
${loading && messages.length > 0 && html`<div class="ext-chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
||||
${hasMore && !loading && html`
|
||||
<button class="chat-thread__load-more" onClick=${loadMore}>
|
||||
<button class="ext-chat-thread__load-more" onClick=${loadMore}>
|
||||
Load older messages
|
||||
</button>`}
|
||||
${messages.map(m => html`
|
||||
@@ -466,7 +478,7 @@
|
||||
/>`)}
|
||||
<div ref=${bottomRef} />
|
||||
</div>
|
||||
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
|
||||
${typingText && html`<div class="ext-chat-thread__typing">${typingText}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -533,8 +545,8 @@
|
||||
if (!conversationId) return null;
|
||||
|
||||
return html`
|
||||
<div class="chat-compose">
|
||||
<textarea class="chat-compose__input"
|
||||
<div class="ext-chat-compose">
|
||||
<textarea class="ext-chat-compose__input"
|
||||
ref=${textareaRef}
|
||||
value=${text}
|
||||
placeholder="Type a message\u2026"
|
||||
@@ -553,6 +565,18 @@
|
||||
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
||||
var [addOpen, setAddOpen] = useState(false);
|
||||
var [presence, setPresence] = useState({});
|
||||
var [resolvedNames, setResolvedNames] = useState({});
|
||||
|
||||
// Resolve display names from users table
|
||||
useEffect(() => {
|
||||
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
sw.users.resolveMany(ids).then(map => {
|
||||
var names = {};
|
||||
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||
setResolvedNames(names);
|
||||
});
|
||||
}, [participants]);
|
||||
|
||||
// Query presence
|
||||
useEffect(() => {
|
||||
@@ -580,22 +604,22 @@
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-participants">
|
||||
<div class="chat-participants__header">
|
||||
<div class="ext-chat-participants">
|
||||
<div class="ext-chat-participants__header">
|
||||
<span>Participants (${(participants || []).length})</span>
|
||||
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
||||
</div>
|
||||
<div class="chat-participants__list">
|
||||
<div class="ext-chat-participants__list">
|
||||
${(participants || []).map(p => html`
|
||||
<div key=${p.participant_id} class="chat-participants__item">
|
||||
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
|
||||
<span class="chat-participants__name">
|
||||
${p.display_name || p.participant_id}
|
||||
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
|
||||
<div key=${p.participant_id} class="ext-chat-participants__item">
|
||||
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
|
||||
<span class="ext-chat-participants__name">
|
||||
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
|
||||
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</span>`}
|
||||
</span>
|
||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' chat-participants__status--online' : '')} />
|
||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' ext-chat-participants__status--online' : '')} />
|
||||
${isAdmin && p.participant_id !== currentUserId() && html`
|
||||
<button class="chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
||||
<button class="ext-chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
||||
</div>`)}
|
||||
</div>
|
||||
|
||||
@@ -671,8 +695,8 @@
|
||||
|
||||
return html`
|
||||
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
||||
<div class="chat-new">
|
||||
<div class="chat-new__type">
|
||||
<div class="ext-chat-new">
|
||||
<div class="ext-chat-new__type">
|
||||
<label>
|
||||
<input type="radio" name="convType" value="group"
|
||||
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
||||
@@ -685,14 +709,14 @@
|
||||
</label>
|
||||
</div>
|
||||
${type === 'group' && html`
|
||||
<input class="chat-new__title" type="text" value=${title}
|
||||
<input class="ext-chat-new__title" type="text" value=${title}
|
||||
placeholder="Conversation title (optional)"
|
||||
onInput=${e => setTitle(e.target.value)} />`}
|
||||
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
||||
${selected.length > 0 && html`
|
||||
<div class="chat-new__selected">
|
||||
<div class="ext-chat-new__selected">
|
||||
${selected.map(u => html`
|
||||
<span key=${u.id} class="chat-new__chip">
|
||||
<span key=${u.id} class="ext-chat-new__chip">
|
||||
${u.display_name || u.username}
|
||||
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
||||
</span>`)}
|
||||
@@ -806,28 +830,41 @@
|
||||
var selectedConv = conversations.find(c => c.id === selectedId);
|
||||
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="chat-loading"><${Spinner} /></div>`;
|
||||
}
|
||||
// ── Shell topbar ───────────────────────────
|
||||
useEffect(() => {
|
||||
if (!sw.shell?.topbar) return;
|
||||
sw.shell.topbar.setTitle('Chat');
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<div class="chat-app">
|
||||
<${Topbar} title="Chat">
|
||||
${selectedId && html`
|
||||
<span class="chat-topbar__thread-title">${threadTitle}</span>
|
||||
useEffect(() => {
|
||||
if (!sw.shell?.topbar) return;
|
||||
if (selectedId) {
|
||||
sw.shell.topbar.setSlot(html`
|
||||
<span class="ext-chat-topbar__thread-title">${threadTitle}</span>
|
||||
<${Button} size="sm" variant="secondary"
|
||||
onClick=${() => setShowParticipants(!showParticipants)}>
|
||||
${showParticipants ? 'Hide' : 'People'}
|
||||
<//>`}
|
||||
<//>
|
||||
<div class="chat-body">
|
||||
`);
|
||||
} else {
|
||||
sw.shell.topbar.setSlot(null);
|
||||
}
|
||||
}, [selectedId, threadTitle, showParticipants]);
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="ext-chat-loading"><${Spinner} /></div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="ext-chat-app">
|
||||
<div class="ext-chat-body">
|
||||
<${ConversationList}
|
||||
selected=${selectedId}
|
||||
onSelect=${selectConversation}
|
||||
onNew=${() => setShowNew(true)}
|
||||
conversations=${conversations}
|
||||
unread=${unread} />
|
||||
<div class="chat-main">
|
||||
<div class="ext-chat-main">
|
||||
<${MessageThread}
|
||||
conversationId=${selectedId}
|
||||
participants=${participants} />
|
||||
|
||||