Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 680ec3b897 |
@@ -1,16 +1,18 @@
|
|||||||
# .gitea/workflows/ci.yaml
|
# .gitea/workflows/ci.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - CI/CD Pipeline (v0.17.3)
|
# Armature - CI/CD Pipeline (v0.18.0)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Single unified image (Go backend + nginx frontend).
|
# Single unified image (Go backend + nginx frontend).
|
||||||
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
|
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
|
||||||
#
|
#
|
||||||
# Pipeline:
|
# Pipeline:
|
||||||
# 0. Detect changes (path-based gating for all downstream jobs)
|
# 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)
|
# 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled)
|
||||||
# 1c. Go test (PG) — PG store + handlers against Postgres (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):
|
# Test coverage mapping (no package tested by zero jobs):
|
||||||
# Unit packages (auto-discovered) → test-sqlite (race)
|
# Unit packages (auto-discovered) → test-sqlite (race)
|
||||||
@@ -23,28 +25,30 @@
|
|||||||
# Path gating rules:
|
# Path gating rules:
|
||||||
# src/, src/editor/ → frontend tests
|
# src/, src/editor/ → frontend tests
|
||||||
# server/, scripts/db-* → backend tests (PG + SQLite)
|
# server/, scripts/db-* → backend tests (PG + SQLite)
|
||||||
|
# packages/ → test-runners + e2e-smoke
|
||||||
# Dockerfile*, k8s/, .gitea/ → all tests (infra change)
|
# Dockerfile*, k8s/, .gitea/ → all tests (infra change)
|
||||||
# docs/, *.md → skip all tests + deploy
|
# ci/ → infra (CI scripts)
|
||||||
# VERSION, scripts/* → frontend + backend tests
|
# docs/, *.md → build-and-deploy only (docs served in-app)
|
||||||
|
# VERSION, scripts/* → build-and-deploy only (no tests)
|
||||||
# Tags (v*) → always full pipeline
|
# Tags (v*) → always full pipeline
|
||||||
#
|
#
|
||||||
# Deployment mapping (single domain, path-based):
|
# Deployment mapping (single domain, path-based):
|
||||||
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
|
# PR → FE + BE :dev → armature.DOMAIN/dev/ (DB wipe + fresh schema)
|
||||||
# Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only)
|
# Push to main → FE + BE :test → armature.DOMAIN/test/ (migrate only)
|
||||||
# Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only)
|
# Tag v* → FE + BE :latest → armature.DOMAIN/ (migrate only)
|
||||||
# → Unified :latest → Docker Hub (not deployed)
|
# → Unified :latest → Docker Hub (not deployed)
|
||||||
#
|
#
|
||||||
# Database lifecycle:
|
# Database lifecycle:
|
||||||
# CI: bootstrap (admin creds) → create DB + role + extensions
|
# CI: bootstrap (admin creds) → create DB + role + extensions
|
||||||
# CI: dev wipe (app creds) → drop tables for fresh install test
|
# CI: dev wipe (app creds) → drop tables for fresh install test
|
||||||
# BE: auto-migrate on startup → schema_migrations tracking
|
# BE: auto-migrate on startup → schema_migrations tracking
|
||||||
# BE: bootstrap admin from SWITCHBOARD_ADMIN_* env vars (upsert on every restart)
|
# BE: bootstrap admin from ARMATURE_ADMIN_* env vars (upsert on every restart)
|
||||||
#
|
#
|
||||||
# Shared PG safety:
|
# Shared PG safety:
|
||||||
# - Bootstrap uses IF NOT EXISTS (idempotent)
|
# - Bootstrap uses IF NOT EXISTS (idempotent)
|
||||||
# - Dev wipe REFUSES to run on databases not ending in _dev
|
# - Dev wipe REFUSES to run on databases not ending in _dev
|
||||||
# - Admin creds never reach the backend pods
|
# - Admin creds never reach the backend pods
|
||||||
# - Each env has its own DB name (switchboard_core_{dev,test,})
|
# - Each env has its own DB name (armature_{dev,test,})
|
||||||
#
|
#
|
||||||
# Required Gitea Variables:
|
# Required Gitea Variables:
|
||||||
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
# Required Gitea Secrets:
|
# Required Gitea Secrets:
|
||||||
# POSTGRES_USER, POSTGRES_PASSWORD
|
# POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
|
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
|
||||||
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
|
# ARMATURE_ADMIN_USERNAME, ARMATURE_ADMIN_PASSWORD, ARMATURE_ADMIN_EMAIL
|
||||||
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
||||||
# PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing)
|
# PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing)
|
||||||
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
||||||
@@ -88,8 +92,8 @@ env:
|
|||||||
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
|
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
|
||||||
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
|
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
|
||||||
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
||||||
IMAGE: ${{ vars.REGISTRY }}/switchboard/core
|
IMAGE: ${{ vars.REGISTRY }}/armature
|
||||||
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/switchboard-core' }}
|
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/armature' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Stage 0: Detect Changed Paths ──────────────
|
# ── Stage 0: Detect Changed Paths ──────────────
|
||||||
@@ -100,6 +104,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
frontend: ${{ steps.filter.outputs.frontend }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
backend: ${{ steps.filter.outputs.backend }}
|
backend: ${{ steps.filter.outputs.backend }}
|
||||||
|
packages: ${{ steps.filter.outputs.packages }}
|
||||||
infra: ${{ steps.filter.outputs.infra }}
|
infra: ${{ steps.filter.outputs.infra }}
|
||||||
docs_only: ${{ steps.filter.outputs.docs_only }}
|
docs_only: ${{ steps.filter.outputs.docs_only }}
|
||||||
steps:
|
steps:
|
||||||
@@ -137,7 +142,7 @@ jobs:
|
|||||||
echo "${CHANGED}" | sed 's/^/ /'
|
echo "${CHANGED}" | sed 's/^/ /'
|
||||||
|
|
||||||
# Classify
|
# 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
|
while IFS= read -r file; do
|
||||||
[[ -z "$file" ]] && continue
|
[[ -z "$file" ]] && continue
|
||||||
case "$file" in
|
case "$file" in
|
||||||
@@ -145,19 +150,23 @@ jobs:
|
|||||||
FE=true ;;
|
FE=true ;;
|
||||||
server/*|scripts/db-*)
|
server/*|scripts/db-*)
|
||||||
BE=true ;;
|
BE=true ;;
|
||||||
|
packages/*)
|
||||||
|
PKG=true ;;
|
||||||
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
|
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
|
||||||
INFRA=true ;;
|
INFRA=true ;;
|
||||||
docs/*|*.md|CHANGELOG.md|LICENSE)
|
docs/*|*.md|CHANGELOG.md|LICENSE)
|
||||||
DOCS=true ;;
|
DOCS=true ;;
|
||||||
VERSION|scripts/*)
|
VERSION|scripts/*)
|
||||||
FE=true; BE=true ;;
|
DOCS=true ;; # deploy-only — scripts/db-* already matched as BE above
|
||||||
|
ci/*)
|
||||||
|
INFRA=true ;;
|
||||||
*)
|
*)
|
||||||
OTHER=true ;;
|
OTHER=true ;;
|
||||||
esac
|
esac
|
||||||
done <<< "${CHANGED}"
|
done <<< "${CHANGED}"
|
||||||
|
|
||||||
# Docs-only: only docs changed, nothing else
|
# 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
|
DOCS_ONLY=true
|
||||||
else
|
else
|
||||||
DOCS_ONLY=false
|
DOCS_ONLY=false
|
||||||
@@ -165,6 +174,7 @@ jobs:
|
|||||||
|
|
||||||
echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
|
echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
|
||||||
echo "backend=${BE}" >> "$GITHUB_OUTPUT"
|
echo "backend=${BE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "packages=${PKG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
|
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
|
||||||
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
|
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -172,6 +182,7 @@ jobs:
|
|||||||
echo "━━━ Change Detection ━━━"
|
echo "━━━ Change Detection ━━━"
|
||||||
echo " frontend: ${FE}"
|
echo " frontend: ${FE}"
|
||||||
echo " backend: ${BE}"
|
echo " backend: ${BE}"
|
||||||
|
echo " packages: ${PKG}"
|
||||||
echo " infra: ${INFRA}"
|
echo " infra: ${INFRA}"
|
||||||
echo " docs_only: ${DOCS_ONLY}"
|
echo " docs_only: ${DOCS_ONLY}"
|
||||||
|
|
||||||
@@ -319,7 +330,7 @@ jobs:
|
|||||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||||
APP_USER: ${{ secrets.POSTGRES_USER }}
|
APP_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
DB_NAME: switchboard_core_ci
|
DB_NAME: armature_ci
|
||||||
run: |
|
run: |
|
||||||
echo "━━━ CI Test Database Setup ━━━"
|
echo "━━━ CI Test Database Setup ━━━"
|
||||||
# Create DB for Go integration tests (admin creds)
|
# Create DB for Go integration tests (admin creds)
|
||||||
@@ -363,23 +374,93 @@ jobs:
|
|||||||
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
psql -c "DROP DATABASE IF EXISTS switchboard_core_ci;" postgres
|
psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres
|
||||||
echo "✓ Dropped CI test database"
|
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 ─────────
|
# ── Stage 2: Build, Database, Deploy ─────────
|
||||||
#
|
#
|
||||||
# Depends on all test jobs. Skipped jobs (due to path gating)
|
# Depends on all test jobs. Skipped jobs (due to path gating)
|
||||||
# are treated as successful — no blocking.
|
# 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:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite]
|
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite, test-runners, e2e-smoke]
|
||||||
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
|
# Run unless: a needed job failed or the workflow was cancelled.
|
||||||
# Skipped test jobs (path-gated) are fine — they don't block.
|
# Skipped test jobs (path-gated) are fine — they don't block.
|
||||||
|
# Always deploys — docs are served in-app, VERSION needs a build.
|
||||||
if: |
|
if: |
|
||||||
!cancelled() && !failure() &&
|
!cancelled() && !failure()
|
||||||
needs.detect-changes.outputs.docs_only != 'true'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -395,9 +476,9 @@ jobs:
|
|||||||
- name: Determine environment
|
- name: Determine environment
|
||||||
id: setup
|
id: setup
|
||||||
run: |
|
run: |
|
||||||
# All environments share one host: switchboard.DOMAIN
|
# All environments share one host: armature.DOMAIN
|
||||||
# Environments are separated by path prefix (BASE_PATH)
|
# Environments are separated by path prefix (BASE_PATH)
|
||||||
DEPLOY_HOST="switchboard.${DOMAIN}"
|
DEPLOY_HOST="armature.${DOMAIN}"
|
||||||
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
|
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
|
||||||
@@ -406,13 +487,14 @@ jobs:
|
|||||||
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
|
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core_dev" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature_dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$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"
|
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
||||||
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
||||||
VERSION="${{ gitea.ref_name }}"
|
VERSION="${{ gitea.ref_name }}"
|
||||||
@@ -422,13 +504,14 @@ jobs:
|
|||||||
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=2" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=2" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=500m" >> "$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 "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
@@ -437,13 +520,14 @@ jobs:
|
|||||||
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
|
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core_test" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature_test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -579,14 +663,17 @@ jobs:
|
|||||||
# ── Push to Docker Hub (release only) ────────
|
# ── Push to Docker Hub (release only) ────────
|
||||||
- name: Push to Docker Hub
|
- name: Push to Docker Hub
|
||||||
if: steps.setup.outputs.is_release == 'true'
|
if: steps.setup.outputs.is_release == 'true'
|
||||||
|
env:
|
||||||
|
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:latest
|
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:latest
|
||||||
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
|
if [[ -n "${DH_TOKEN}" ]]; then
|
||||||
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
|
echo "${DH_TOKEN}" | docker login -u "${DH_USER}" --password-stdin
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
|
||||||
docker push ${DOCKERHUB_IMAGE}:latest
|
docker push ${DOCKERHUB_IMAGE}:latest
|
||||||
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
|
echo "✓ Pushed to Docker Hub"
|
||||||
else
|
else
|
||||||
echo "⚠ Docker Hub credentials not configured, skipping push"
|
echo "⚠ Docker Hub credentials not configured, skipping push"
|
||||||
fi
|
fi
|
||||||
@@ -600,31 +687,31 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PG_USER: ${{ secrets.POSTGRES_USER }}
|
PG_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
|
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
|
SW_ADMIN_USER: ${{ secrets.ARMATURE_ADMIN_USERNAME }}
|
||||||
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
|
SW_ADMIN_PASS: ${{ secrets.ARMATURE_ADMIN_PASSWORD }}
|
||||||
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
|
SW_ADMIN_EMAIL: ${{ secrets.ARMATURE_ADMIN_EMAIL }}
|
||||||
SEED_USERS: ${{ vars.SEED_USERS }}
|
SEED_USERS: ${{ vars.SEED_USERS }}
|
||||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||||
run: |
|
run: |
|
||||||
kubectl create secret generic switchboard-db-credentials \
|
kubectl create secret generic armature-db-credentials \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=POSTGRES_USER="${PG_USER}" \
|
--from-literal=POSTGRES_USER="${PG_USER}" \
|
||||||
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
|
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-admin \
|
kubectl create secret generic armature-admin \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=username="${SW_ADMIN_USER}" \
|
--from-literal=username="${SW_ADMIN_USER}" \
|
||||||
--from-literal=password="${SW_ADMIN_PASS}" \
|
--from-literal=password="${SW_ADMIN_PASS}" \
|
||||||
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-seed-users \
|
kubectl create secret generic armature-seed-users \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=users="${SEED_USERS}" \
|
--from-literal=users="${SEED_USERS}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-encryption \
|
kubectl create secret generic armature-encryption \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
@@ -639,7 +726,7 @@ jobs:
|
|||||||
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
|
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
|
||||||
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
||||||
run: |
|
run: |
|
||||||
kubectl create secret generic switchboard-s3 \
|
kubectl create secret generic armature-s3 \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
|
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
|
||||||
--from-literal=S3_BUCKET="${S3_BUCKET}" \
|
--from-literal=S3_BUCKET="${S3_BUCKET}" \
|
||||||
@@ -667,13 +754,14 @@ jobs:
|
|||||||
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||||
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||||
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
||||||
|
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
|
||||||
run: |
|
run: |
|
||||||
# Render PVC first (must exist before backend references it)
|
# Render PVC first (must exist before backend references it)
|
||||||
if [[ -n "${STORAGE_CLASS}" ]]; then
|
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||||
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
|
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
envsubst < k8s/switchboard.yaml > /tmp/switchboard.yaml
|
envsubst < k8s/armature.yaml > /tmp/armature.yaml
|
||||||
envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml
|
envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml
|
||||||
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
||||||
|
|
||||||
@@ -687,7 +775,7 @@ jobs:
|
|||||||
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
|
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kubectl apply -f /tmp/switchboard.yaml
|
kubectl apply -f /tmp/armature.yaml
|
||||||
|
|
||||||
# Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs.
|
# Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs.
|
||||||
# First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin)
|
# First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin)
|
||||||
@@ -706,8 +794,8 @@ jobs:
|
|||||||
# Traefik invalidates the entire router if it references a missing middleware,
|
# Traefik invalidates the entire router if it references a missing middleware,
|
||||||
# which kills all routes for this path prefix.
|
# which kills all routes for this path prefix.
|
||||||
if [[ "${MW_READY}" == "true" ]]; then
|
if [[ "${MW_READY}" == "true" ]]; then
|
||||||
MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd"
|
MW_REF="${NAMESPACE}-armature-retry${DEPLOY_SUFFIX}@kubernetescrd"
|
||||||
kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \
|
kubectl annotate ingress "armature${DEPLOY_SUFFIX}" \
|
||||||
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
|
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
|
||||||
--namespace="${NAMESPACE}" --overwrite
|
--namespace="${NAMESPACE}" --overwrite
|
||||||
echo " ✓ Ingress annotated with retry middleware"
|
echo " ✓ Ingress annotated with retry middleware"
|
||||||
@@ -718,20 +806,20 @@ jobs:
|
|||||||
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
||||||
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
|
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
|
||||||
run: |
|
run: |
|
||||||
kubectl rollout restart deployment/switchboard${SUFFIX} -n ${NAMESPACE}
|
kubectl rollout restart deployment/armature${SUFFIX} -n ${NAMESPACE}
|
||||||
kubectl rollout status deployment/switchboard${SUFFIX} -n ${NAMESPACE} --timeout=180s
|
kubectl rollout status deployment/armature${SUFFIX} -n ${NAMESPACE} --timeout=180s
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "━━━ Pod Status ━━━"
|
echo "━━━ Pod Status ━━━"
|
||||||
kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV}
|
kubectl get pods -n ${NAMESPACE} -l app=armature,env=${ENV}
|
||||||
|
|
||||||
READY=$(kubectl get deployment switchboard${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
|
READY=$(kubectl get deployment armature${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
|
||||||
|
|
||||||
if [[ "${READY:-0}" -gt 0 ]]; then
|
if [[ "${READY:-0}" -gt 0 ]]; then
|
||||||
echo "✅ Healthy: ${READY} pods ready"
|
echo "✅ Healthy: ${READY} pods ready"
|
||||||
else
|
else
|
||||||
echo "❌ Unhealthy: ${READY:-0} pods ready"
|
echo "❌ Unhealthy: ${READY:-0} pods ready"
|
||||||
kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30
|
kubectl logs -n ${NAMESPACE} -l app=armature,env=${ENV} --tail=30
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
1427
CHANGELOG.md
@@ -1,4 +1,4 @@
|
|||||||
# Contributing to Switchboard Core
|
# Contributing to Armature
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd server
|
cd server
|
||||||
go build -o switchboard-core .
|
go build -o armature .
|
||||||
DB_DRIVER=sqlite DATABASE_URL=/tmp/switchboard.db ./switchboard-core
|
DB_DRIVER=sqlite DATABASE_URL=/tmp/armature.db ./armature
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker (recommended):**
|
**Docker (recommended):**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core — Unified Dockerfile
|
# Armature — Unified Dockerfile
|
||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build Go backend
|
# Stage 1: Build Go backend
|
||||||
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
||||||
@@ -20,7 +20,7 @@ COPY server/go.mod server/go.sum* ./
|
|||||||
COPY server/ .
|
COPY server/ .
|
||||||
COPY VERSION ./
|
COPY VERSION ./
|
||||||
RUN go mod download && go mod tidy && go mod verify
|
RUN go mod download && go mod tidy && go mod verify
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/switchboard .
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/armature .
|
||||||
|
|
||||||
# ── Stage 2: Vendor JS libs ─────────────────
|
# ── Stage 2: Vendor JS libs ─────────────────
|
||||||
FROM node:20-alpine AS vendor
|
FROM node:20-alpine AS vendor
|
||||||
@@ -70,7 +70,7 @@ FROM nginx:1-alpine
|
|||||||
RUN apk add --no-cache bash git
|
RUN apk add --no-cache bash git
|
||||||
|
|
||||||
# Go backend binary
|
# Go backend binary
|
||||||
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
|
COPY --from=backend /bin/armature /usr/local/bin/armature
|
||||||
COPY --from=backend /app/database/migrations /app/database/migrations
|
COPY --from=backend /app/database/migrations /app/database/migrations
|
||||||
|
|
||||||
# Frontend static files
|
# Frontend static files
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core — Builder Image
|
# Armature — Builder Image
|
||||||
# ============================================
|
# ============================================
|
||||||
# Pre-caches Go modules and Node dependencies
|
# Pre-caches Go modules and Node dependencies
|
||||||
# for faster custom builds. Use as a base in
|
# for faster custom builds. Use as a base in
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
# download on every build.
|
# download on every build.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# FROM ghcr.io/switchboard-core/builder:latest AS go-builder
|
# FROM ghcr.io/armature/builder:latest AS go-builder
|
||||||
# COPY server/ /app/
|
# COPY server/ /app/
|
||||||
# RUN cd /app && go build -o /bin/switchboard .
|
# RUN cd /app && go build -o /bin/armature .
|
||||||
#
|
#
|
||||||
# Or build this image locally:
|
# Or build this image locally:
|
||||||
# docker build -f Dockerfile.builder -t switchboard-builder .
|
# docker build -f Dockerfile.builder -t armature-builder .
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ── Go module cache ─────────────────────────
|
# ── Go module cache ─────────────────────────
|
||||||
@@ -61,5 +61,5 @@ RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="Switchboard Core Builder"
|
LABEL org.opencontainers.image.title="Armature Builder"
|
||||||
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Switchboard Core builds"
|
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Armature builds"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Switchboard Core
|
# Armature
|
||||||
|
|
||||||
A self-hosted extension platform. Identity, teams, permissions, workflows,
|
A self-hosted extension platform. Identity, teams, permissions, workflows,
|
||||||
and a package system. Everything else ships as installable extensions.
|
and a package system. Everything else ships as installable extensions.
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
Switchboard Core is the kernel. It provides the primitives that extensions
|
Armature is the kernel. It provides the primitives that extensions
|
||||||
build on: users, teams, groups, scoped credentials, a Starlark sandbox,
|
build on: users, teams, groups, scoped credentials, a Starlark sandbox,
|
||||||
workflow orchestration, notifications, and a package installer. It does not
|
workflow orchestration, notifications, and a package installer. It does not
|
||||||
include AI chat, providers, personas, or any domain-specific features —
|
include AI chat, providers, personas, or any domain-specific features —
|
||||||
@@ -27,7 +27,7 @@ docker compose up --build
|
|||||||
# → http://localhost:3000 (admin/admin)
|
# → http://localhost:3000 (admin/admin)
|
||||||
|
|
||||||
# Or from source
|
# Or from source
|
||||||
git clone <repo-url> && cd switchboard-core
|
git clone <repo-url> && cd armature
|
||||||
cp server/.env.example server/.env # edit DB credentials
|
cp server/.env.example server/.env # edit DB credentials
|
||||||
cd server && go run .
|
cd server && go run .
|
||||||
# → http://localhost:8080
|
# → http://localhost:8080
|
||||||
|
|||||||
472
ROADMAP.md
@@ -1,170 +1,330 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Armature — Roadmap
|
||||||
|
|
||||||
## Current: v0.6.7 — Native mTLS
|
## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages
|
||||||
|
|
||||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||||
|
is an extension.
|
||||||
|
|
||||||
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
||||||
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
||||||
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
|
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
|
||||||
Audit log · Notifications · Scheduled tasks
|
Audit log · Notifications · Scheduled tasks · Cluster registry + HA ·
|
||||||
|
Extension composability (slots/contributes/cross-package calls)
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.6.0 — MVP
|
## Completed — v0.6.x through v0.8.x
|
||||||
|
|
||||||
Extension, communication, and operations tracks converge. First
|
All completed work is documented in `CHANGELOG.md`.
|
||||||
externally usable release.
|
|
||||||
|
|
||||||
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 |
|
| Version | Title |
|
||||||
|------|--------|-------------|
|
|---------|-------|
|
||||||
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. |
|
| v0.7.0 | Shell Contract + Surface Audit + Rebrand |
|
||||||
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. |
|
| v0.7.1 | Surface Runner Framework |
|
||||||
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). |
|
| v0.7.2 | Package Runners + CI Gate |
|
||||||
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. |
|
| v0.7.3 | Extension Shell Migration |
|
||||||
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. |
|
| v0.7.4 | Documentation + Surface Work |
|
||||||
| 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). |
|
| v0.7.5 | Headless E2E + CI Gate |
|
||||||
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. |
|
| v0.7.6 | Code Hygiene + Test Coverage |
|
||||||
| 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. |
|
| v0.7.7 | API Tokens + Extension Permissions |
|
||||||
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. |
|
| v0.7.8 | Bug Fixes & Admin Gaps |
|
||||||
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). |
|
| v0.7.9 | Workflow Independence Audit |
|
||||||
| 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. |
|
| v0.7.10 | Workflow Handoff + Assignment UI |
|
||||||
| 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. |
|
| 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 |
|
| Version | Title |
|
||||||
|------|--------|-------------|
|
|---------|-------|
|
||||||
| 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). |
|
| v0.8.0 | `files` Module |
|
||||||
| Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
|
| v0.8.1 | `workspace` Module |
|
||||||
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
|
| v0.8.2 | Capability Negotiation |
|
||||||
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
|
| v0.8.3 | Vector Column Type |
|
||||||
| 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. |
|
| v0.8.4 | Documentation Refresh + Surface Sizing Fix |
|
||||||
| 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.8.5 | Extension Composability |
|
||||||
|
|
||||||
### 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. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.6.x — Hardening
|
## Planned
|
||||||
|
|
||||||
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.9.x — Multi-Surface Packages + Workflow Redesign
|
||||||
|
|
||||||
### v0.6.3 — Dead Code Sweep + Registry Fix
|
**v0.9.0 — Multi-Surface Packages** *(completed)*
|
||||||
|
|
||||||
Pure cleanup. No behavior changes except fixing the broken registry install flow.
|
Packages declare a `surfaces` array with per-path access controls,
|
||||||
|
titles, and layouts. Unified route tree dispatches between surface
|
||||||
|
rendering and ext API calls. `sw.navigate()` for client-side sub-path
|
||||||
|
routing. Design doc: `docs/DESIGN-multi-surface.md`.
|
||||||
|
|
||||||
| Step | Status | Description |
|
**v0.9.1 — Server-Side Sub-Path Routing** *(completed)*
|
||||||
|------|--------|-------------|
|
|
||||||
| 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
|
Consolidated root and catch-all route handlers into a unified dispatcher.
|
||||||
|
Added `aggregateAccess()` for early auth short-circuit on all-authenticated
|
||||||
|
packages. SDK seeds initial history state for back-button resilience.
|
||||||
|
8 handler integration tests + 5 aggregateAccess unit tests.
|
||||||
|
|
||||||
Structural move: cluster dashboard becomes an Admin tab. Better home for health/metrics — shared context with other admin panels, no separate nav entry.
|
**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** *(completed)*
|
||||||
|
|
||||||
| Step | Status | Description |
|
Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go`
|
||||||
|------|--------|-------------|
|
(4 exported functions) and snapshot parsers into `models/snapshot.go`.
|
||||||
| "Health / Metrics" admin tab | ✅ | New tab in Admin surface. DB-agnostic metrics for all deployments. Cluster cards conditional on PG + multi-node detection. |
|
Standardized on wrapped snapshot format. ~350 lines of duplication removed.
|
||||||
| 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. |
|
Design doc: `docs/DESIGN-workflow-redesign.md`.
|
||||||
| 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
|
**v0.9.3 — Team User Roles** *(completed)*
|
||||||
|
|
||||||
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.
|
Many-to-many `team_user_roles` table. `RequireRole()` middleware.
|
||||||
|
Manifest `requires_roles` field (advisory). Starlark `teams` module
|
||||||
|
with `get_member_roles()` and `has_role()`. Team-admin UI with role
|
||||||
|
badge chips and assignment dropdown. 10 new tests.
|
||||||
|
|
||||||
| Step | Status | Description |
|
**v0.9.4 — Package Adoption + Roles** *(completed)*
|
||||||
|------|--------|-------------|
|
|
||||||
| 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
|
`adoptable` manifest field + `team_role_catalog` table. When a team
|
||||||
|
adopts an adoptable package, the package's `requires_roles` auto-populate
|
||||||
|
into the team's role catalog. Adopted packages reference the original via
|
||||||
|
`adopted_from` column (shared assets, no disk duplication).
|
||||||
|
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
|
||||||
|
4 new endpoints, migration 017, 11 new tests.
|
||||||
|
|
||||||
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`.
|
**v0.9.5 — Typed Forms → SDK Primitive** *(completed)*
|
||||||
|
|
||||||
| Step | Status | Description |
|
Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
||||||
|------|--------|-------------|
|
`models/workflow.go` into a standalone `forms` package. REST endpoint
|
||||||
| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. |
|
`POST /api/v1/forms/validate`. Starlark `forms.validate()` module.
|
||||||
| 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. |
|
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
|
||||||
| `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`. |
|
Manifest `form_template` accepted at package level. 16 new tests.
|
||||||
| 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. |
|
|
||||||
| `switchboard-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
|
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)*
|
||||||
|
|
||||||
Final pass before public release. Security, correctness, and developer experience.
|
`stage_type` deprecated (no longer validated, defaults to "simple").
|
||||||
|
`stage_mode` collapsed from 4→3 values: form / delegated / automated.
|
||||||
|
"review" mapped to "form" on input; review surface removed (~110 lines).
|
||||||
|
Migration 018. 4 package manifests updated.
|
||||||
|
|
||||||
| Step | Status | Description |
|
**v0.9.7 — Full Read/Write Workflow Starlark Module** *(completed)*
|
||||||
|------|--------|-------------|
|
|
||||||
| 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.
|
`WorkflowEngine` interface extracted in sandbox package to break
|
||||||
|
circular import. Four write builtins added: `workflow.start()`,
|
||||||
|
`workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()`.
|
||||||
|
`instanceToDict` and `signoffToDict` helpers shared by read+write paths.
|
||||||
|
6 new tests.
|
||||||
|
|
||||||
|
**v0.9.8 — Conditional Routing → SDK Primitive** *(completed)*
|
||||||
|
|
||||||
|
`routing.evaluate(rules, data)` Starlark builtin — a generic decision
|
||||||
|
engine reusable by any extension. 10 operators (exists, not_exists, eq,
|
||||||
|
neq, gt, lt, gte, lte, in, contains), first-match-wins, returns target
|
||||||
|
string or None. Always available (pure computation, no permission).
|
||||||
|
8 new tests.
|
||||||
|
|
||||||
|
**v0.9.9 — Surface Access via Roles** *(completed)*
|
||||||
|
|
||||||
|
`role:ROLENAME` surface access level. User must hold the role in any
|
||||||
|
team (any-team semantics, no URL context needed). `evaluateAccess`
|
||||||
|
promoted to Engine method for store access. `HasRoleInAnyTeam` store
|
||||||
|
method queries both primary and additional roles. Admin bypass, fail-
|
||||||
|
closed on nil store. 10 new tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Post-MVP
|
### 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.)
|
Panels are a new kernel rendering tier between surfaces (full-page) and
|
||||||
- Rich media extensions: image generation, code sandbox, STT/TTS
|
block renderers (inline). They solve composable companion views — e.g.,
|
||||||
- Desktop app (Tauri or Electron)
|
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`.
|
||||||
- Sidecar tier: container-based extensions
|
|
||||||
- Federation: cross-instance package sharing
|
| Version | Title |
|
||||||
- Plugin marketplace with signing and review
|
|---------|-------|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
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 +332,42 @@ Then ship.
|
|||||||
|
|
||||||
| Decision | Rationale |
|
| 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. |
|
| Tasks → extension | Three trigger primitives replace the monolithic scheduler. |
|
||||||
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). |
|
| Sessions removed | Workflow instances with dedicated storage replace kernel sessions. |
|
||||||
| `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. |
|
| `custom` stage mode | Delegates to a surface package, proving extension composability. |
|
||||||
| 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. |
|
| Providers removed from kernel | Connections + Starlark `provider.complete` as the interface. |
|
||||||
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. |
|
| Kernel permissions simplified | 6 platform permissions. Extensions define their own. |
|
||||||
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. |
|
| Preact+htm retained | 3KB runtime, no build step, KISS. |
|
||||||
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. |
|
| Single Docker image | Go binary + assets + migrations. |
|
||||||
| 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. |
|
| Admin → RBAC group | Grant check replaces 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. |
|
| Settings cascade | Scope auth + `user_overridable`. Two orthogonal axes. |
|
||||||
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
| Chat as extension, not kernel | Zero kernel awareness. Proves extensibility thesis. |
|
||||||
| 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. |
|
| PG as consensus layer | UNLOGGED node_registry + LISTEN/NOTIFY. No etcd/Consul/Redis. |
|
||||||
| 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. |
|
| Two trigger tiers | Extension-declared (full sandbox) vs user ad-hoc (restricted). |
|
||||||
| 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. |
|
| Two-slot topbar model | Left slot (title/branding) + center slot (`flex: 1`, tabs/pickers). |
|
||||||
| 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. |
|
| `db` module is the structured store | No separate KV primitive. Extensions declare tables. |
|
||||||
| 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. |
|
| `files` module rides ObjectStore | No new kernel tables. Metadata as companion objects. |
|
||||||
| 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. |
|
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
||||||
| 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=*`. |
|
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
||||||
| 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. |
|
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
||||||
| 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: switchboard
|
name: armature
|
||||||
description: Switchboard Core — self-hosted extension platform
|
description: Armature — self-hosted extension platform
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
|
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
|
||||||
home: https://gobha.ai
|
home: https://gobha.ai
|
||||||
sources:
|
sources:
|
||||||
- https://git.gobha.me/switchboard/core
|
- https://git.gobha.me/armature/core
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: xcaliber
|
- name: xcaliber
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
# Switchboard Core — PrometheusRule alerts (v0.33.0)
|
# Armature Core — PrometheusRule alerts (v0.33.0)
|
||||||
# Source file for the Helm template. Deploy via:
|
# Source file for the Helm template. Deploy via:
|
||||||
# monitoring.prometheusRule.enabled: true
|
# monitoring.prometheusRule.enabled: true
|
||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: PrometheusRule
|
kind: PrometheusRule
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard-alerts
|
name: armature-alerts
|
||||||
spec:
|
spec:
|
||||||
groups:
|
groups:
|
||||||
- name: switchboard.rules
|
- name: armature.rules
|
||||||
rules:
|
rules:
|
||||||
# Pod restart (possible OOM)
|
# Pod restart (possible OOM)
|
||||||
- alert: SwitchboardPodRestart
|
- alert: ArmaturePodRestart
|
||||||
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
||||||
for: 0m
|
for: 0m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Switchboard backend pod restarted (possible OOM)"
|
summary: "Armature backend pod restarted (possible OOM)"
|
||||||
description: "Container {{ $labels.container }} in pod {{ $labels.pod }} restarted."
|
description: "Container {{ $labels.container }} in pod {{ $labels.pod }} restarted."
|
||||||
|
|
||||||
# Provider down for 5+ minutes
|
# Provider down for 5+ minutes
|
||||||
- alert: SwitchboardProviderDown
|
- alert: ArmatureProviderDown
|
||||||
expr: switchboard_provider_status > 2
|
expr: armature_provider_status > 2
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
@@ -29,8 +29,8 @@ spec:
|
|||||||
summary: "Provider {{ $labels.provider_config_id }} is down"
|
summary: "Provider {{ $labels.provider_config_id }} is down"
|
||||||
|
|
||||||
# DB pool >80% utilized
|
# DB pool >80% utilized
|
||||||
- alert: SwitchboardDBPoolExhaustion
|
- alert: ArmatureDBPoolExhaustion
|
||||||
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
|
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -38,10 +38,10 @@ spec:
|
|||||||
summary: "DB connection pool >80% utilized"
|
summary: "DB connection pool >80% utilized"
|
||||||
|
|
||||||
# HTTP 5xx error rate >5%
|
# HTTP 5xx error rate >5%
|
||||||
- alert: SwitchboardHighErrorRate
|
- alert: ArmatureHighErrorRate
|
||||||
expr: >
|
expr: >
|
||||||
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
|
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
|
||||||
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
|
/ sum(rate(armature_http_requests_total[5m])) > 0.05
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -49,10 +49,10 @@ spec:
|
|||||||
summary: "HTTP 5xx error rate exceeds 5%"
|
summary: "HTTP 5xx error rate exceeds 5%"
|
||||||
|
|
||||||
# Task failure rate >25%
|
# Task failure rate >25%
|
||||||
- alert: SwitchboardTaskFailureRate
|
- alert: ArmatureTaskFailureRate
|
||||||
expr: >
|
expr: >
|
||||||
rate(switchboard_task_executions_total{status="error"}[15m])
|
rate(armature_task_executions_total{status="error"}[15m])
|
||||||
/ rate(switchboard_task_executions_total[15m]) > 0.25
|
/ rate(armature_task_executions_total[15m]) > 0.25
|
||||||
for: 10m
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -60,8 +60,8 @@ spec:
|
|||||||
summary: "Task failure rate exceeds 25% over 15 minutes"
|
summary: "Task failure rate exceeds 25% over 15 minutes"
|
||||||
|
|
||||||
# No completions processed in 15 minutes (canary)
|
# No completions processed in 15 minutes (canary)
|
||||||
- alert: SwitchboardNoCompletions
|
- alert: ArmatureNoCompletions
|
||||||
expr: sum(rate(switchboard_completions_total[10m])) == 0
|
expr: sum(rate(armature_completions_total[10m])) == 0
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
|
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
|
||||||
],
|
],
|
||||||
"id": null,
|
"id": null,
|
||||||
"uid": "switchboard-overview",
|
"uid": "armature-overview",
|
||||||
"title": "Switchboard Core — Overview",
|
"title": "Armature — Overview",
|
||||||
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
|
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
|
||||||
"tags": ["switchboard"],
|
"tags": ["armature"],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"schemaVersion": 38,
|
"schemaVersion": 38,
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"name": "namespace",
|
"name": "namespace",
|
||||||
"type": "query",
|
"type": "query",
|
||||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
"query": "label_values(switchboard_http_requests_total, namespace)",
|
"query": "label_values(armature_http_requests_total, namespace)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"current": { "text": "All", "value": "$__all" }
|
"current": { "text": "All", "value": "$__all" }
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"name": "pod",
|
"name": "pod",
|
||||||
"type": "query",
|
"type": "query",
|
||||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
"query": "label_values(switchboard_http_requests_total{namespace=~\"$namespace\"}, pod)",
|
"query": "label_values(armature_http_requests_total{namespace=~\"$namespace\"}, pod)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"current": { "text": "All", "value": "$__all" }
|
"current": { "text": "All", "value": "$__all" }
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum(rate(armature_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "Total req/s"
|
"legendFormat": "Total req/s"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\",status=~\"5..\"}[5m])) / sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum(rate(armature_http_requests_total{namespace=~\"$namespace\",status=~\"5..\"}[5m])) / sum(rate(armature_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "5xx rate"
|
"legendFormat": "5xx rate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -93,15 +93,15 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.50, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p50"
|
"legendFormat": "p50"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.95, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p95"
|
"legendFormat": "p95"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.99, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.99, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p99"
|
"legendFormat": "p99"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(switchboard_websocket_connections{namespace=~\"$namespace\"})",
|
"expr": "sum(armature_websocket_connections{namespace=~\"$namespace\"})",
|
||||||
"legendFormat": "Active"
|
"legendFormat": "Active"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (provider_config_id) (rate(switchboard_completions_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum by (provider_config_id) (rate(armature_completions_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.95, sum by (provider_config_id, le) (rate(switchboard_completion_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])))",
|
"expr": "histogram_quantile(0.95, sum by (provider_config_id, le) (rate(armature_completion_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])))",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (model_id) (rate(switchboard_completion_tokens_total{namespace=~\"$namespace\"}[5m])) * 60",
|
"expr": "sum by (model_id) (rate(armature_completion_tokens_total{namespace=~\"$namespace\"}[5m])) * 60",
|
||||||
"legendFormat": "{{model_id}}"
|
"legendFormat": "{{model_id}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "switchboard_provider_status{namespace=~\"$namespace\"}",
|
"expr": "armature_provider_status{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -179,15 +179,15 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_open_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_open_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "Open"
|
"legendFormat": "Open"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_in_use_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_in_use_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "In Use"
|
"legendFormat": "In Use"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_idle_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_idle_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "Idle"
|
"legendFormat": "Idle"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (status) (rate(switchboard_task_executions_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum by (status) (rate(armature_task_executions_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "{{status}}"
|
"legendFormat": "{{status}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Chat Switchboard {{ .Chart.AppVersion }} deployed.
|
Armature {{ .Chart.AppVersion }} deployed.
|
||||||
|
|
||||||
{{- if .Values.ingress.enabled }}
|
{{- if .Values.ingress.enabled }}
|
||||||
Access the application at:
|
Access the application at:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{/*
|
{{/*
|
||||||
Common labels
|
Common labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.labels" -}}
|
{{- define "armature.labels" -}}
|
||||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
app.kubernetes.io/name: {{ .Chart.Name }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
@@ -12,23 +12,23 @@ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
|
|||||||
{{/*
|
{{/*
|
||||||
Backend selector labels
|
Backend selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.backend.labels" -}}
|
{{- define "armature.backend.labels" -}}
|
||||||
app.kubernetes.io/component: backend
|
app.kubernetes.io/component: backend
|
||||||
{{ include "switchboard.labels" . }}
|
{{ include "armature.labels" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Frontend selector labels
|
Frontend selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.frontend.labels" -}}
|
{{- define "armature.frontend.labels" -}}
|
||||||
app.kubernetes.io/component: frontend
|
app.kubernetes.io/component: frontend
|
||||||
{{ include "switchboard.labels" . }}
|
{{ include "armature.labels" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Secret name
|
Secret name
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.secretName" -}}
|
{{- define "armature.secretName" -}}
|
||||||
{{- if .Values.existingSecret -}}
|
{{- if .Values.existingSecret -}}
|
||||||
{{ .Values.existingSecret }}
|
{{ .Values.existingSecret }}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
@@ -39,21 +39,21 @@ Secret name
|
|||||||
{{/*
|
{{/*
|
||||||
Backend image
|
Backend image
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.backend.image" -}}
|
{{- define "armature.backend.image" -}}
|
||||||
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
|
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Frontend image
|
Frontend image
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.frontend.image" -}}
|
{{- define "armature.frontend.image" -}}
|
||||||
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
|
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Database URL — assembled from postgres.* if url is empty
|
Database URL — assembled from postgres.* if url is empty
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.databaseURL" -}}
|
{{- define "armature.databaseURL" -}}
|
||||||
{{- if .Values.database.url -}}
|
{{- if .Values.database.url -}}
|
||||||
{{ .Values.database.url }}
|
{{ .Values.database.url }}
|
||||||
{{- else if eq .Values.database.driver "sqlite" -}}
|
{{- else if eq .Values.database.driver "sqlite" -}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: ConfigMap
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-config
|
name: {{ .Release.Name }}-config
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
data:
|
data:
|
||||||
PORT: {{ .Values.backend.port | quote }}
|
PORT: {{ .Values.backend.port | quote }}
|
||||||
BASE_PATH: {{ .Values.basePath | quote }}
|
BASE_PATH: {{ .Values.basePath | quote }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: CronJob
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backup
|
name: {{ .Release.Name }}-backup
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
app.kubernetes.io/component: backup
|
app.kubernetes.io/component: backup
|
||||||
spec:
|
spec:
|
||||||
schedule: {{ .Values.backup.schedule | quote }}
|
schedule: {{ .Values.backup.schedule | quote }}
|
||||||
@@ -35,7 +35,7 @@ spec:
|
|||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
FILENAME="switchboard-${TIMESTAMP}.sql.gz"
|
FILENAME="armature-${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
echo "Starting backup: ${FILENAME}"
|
echo "Starting backup: ${FILENAME}"
|
||||||
pg_dump "${DATABASE_URL}" | gzip > "/backup/${FILENAME}"
|
pg_dump "${DATABASE_URL}" | gzip > "/backup/${FILENAME}"
|
||||||
@@ -58,22 +58,22 @@ spec:
|
|||||||
|
|
||||||
# Prune old local backups beyond retention
|
# Prune old local backups beyond retention
|
||||||
cd /backup
|
cd /backup
|
||||||
ls -t switchboard-*.sql.gz 2>/dev/null | tail -n +{{ add1 .Values.backup.retention }} | xargs -r rm -v
|
ls -t armature-*.sql.gz 2>/dev/null | tail -n +{{ add1 .Values.backup.retention }} | xargs -r rm -v
|
||||||
echo "Backup complete"
|
echo "Backup complete"
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: {{ include "switchboard.databaseURL" . | quote }}
|
value: {{ include "armature.databaseURL" . | quote }}
|
||||||
{{- if .Values.backup.s3.enabled }}
|
{{- if .Values.backup.s3.enabled }}
|
||||||
- name: S3_ACCESS_KEY
|
- name: S3_ACCESS_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
key: BACKUP_S3_ACCESS_KEY
|
key: BACKUP_S3_ACCESS_KEY
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_SECRET_KEY
|
- name: S3_SECRET_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
key: BACKUP_S3_SECRET_KEY
|
key: BACKUP_S3_SECRET_KEY
|
||||||
optional: true
|
optional: true
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Deployment
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backend
|
name: {{ .Release.Name }}-backend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.backend.labels" . | nindent 4 }}
|
{{- include "armature.backend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.backend.replicaCount }}
|
replicas: {{ .Values.backend.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
@@ -38,7 +38,7 @@ spec:
|
|||||||
topologyKey: kubernetes.io/hostname
|
topologyKey: kubernetes.io/hostname
|
||||||
containers:
|
containers:
|
||||||
- name: backend
|
- name: backend
|
||||||
image: {{ include "switchboard.backend.image" . }}
|
image: {{ include "armature.backend.image" . }}
|
||||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
@@ -48,10 +48,10 @@ spec:
|
|||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: {{ .Release.Name }}-config
|
name: {{ .Release.Name }}-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: {{ include "switchboard.databaseURL" . | quote }}
|
value: {{ include "armature.databaseURL" . | quote }}
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||||
{{- if .Values.persistence.enabled }}
|
{{- if .Values.persistence.enabled }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Deployment
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-frontend
|
name: {{ .Release.Name }}-frontend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.frontend.labels" . | nindent 4 }}
|
{{- include "armature.frontend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.frontend.replicaCount }}
|
replicas: {{ .Values.frontend.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
@@ -24,7 +24,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: {{ include "switchboard.frontend.image" . }}
|
image: {{ include "armature.frontend.image" . }}
|
||||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}-grafana-dashboard
|
name: {{ include "armature.fullname" . }}-grafana-dashboard
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.grafanaDashboard.labels }}
|
{{- with .Values.monitoring.grafanaDashboard.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
data:
|
data:
|
||||||
switchboard-overview.json: |-
|
armature-overview.json: |-
|
||||||
{{- .Files.Get "dashboards/switchboard-overview.json" | nindent 4 }}
|
{{- .Files.Get "dashboards/armature-overview.json" | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-ingress
|
name: {{ .Release.Name }}-ingress
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }}
|
{{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- with .Values.ingress.annotations }}
|
{{- with .Values.ingress.annotations }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ kind: Middleware
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-retry
|
name: {{ .Release.Name }}-retry
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
retry:
|
retry:
|
||||||
attempts: {{ .Values.ingress.retry.attempts | default 2 }}
|
attempts: {{ .Values.ingress.retry.attempts | default 2 }}
|
||||||
|
|||||||
@@ -2,57 +2,57 @@
|
|||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: PrometheusRule
|
kind: PrometheusRule
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}-alerts
|
name: {{ include "armature.fullname" . }}-alerts
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.prometheusRule.labels }}
|
{{- with .Values.monitoring.prometheusRule.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
groups:
|
groups:
|
||||||
- name: switchboard.rules
|
- name: armature.rules
|
||||||
rules:
|
rules:
|
||||||
- alert: SwitchboardPodRestart
|
- alert: ArmaturePodRestart
|
||||||
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
||||||
for: 0m
|
for: 0m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Switchboard backend pod restarted (possible OOM)"
|
summary: "Armature backend pod restarted (possible OOM)"
|
||||||
- alert: SwitchboardProviderDown
|
- alert: ArmatureProviderDown
|
||||||
expr: switchboard_provider_status > 2
|
expr: armature_provider_status > 2
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Provider {{`{{ $labels.provider_config_id }}`}} is down"
|
summary: "Provider {{`{{ $labels.provider_config_id }}`}} is down"
|
||||||
- alert: SwitchboardDBPoolExhaustion
|
- alert: ArmatureDBPoolExhaustion
|
||||||
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
|
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "DB connection pool >80% utilized"
|
summary: "DB connection pool >80% utilized"
|
||||||
- alert: SwitchboardHighErrorRate
|
- alert: ArmatureHighErrorRate
|
||||||
expr: |
|
expr: |
|
||||||
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
|
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
|
||||||
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
|
/ sum(rate(armature_http_requests_total[5m])) > 0.05
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "HTTP 5xx error rate exceeds 5%"
|
summary: "HTTP 5xx error rate exceeds 5%"
|
||||||
- alert: SwitchboardTaskFailureRate
|
- alert: ArmatureTaskFailureRate
|
||||||
expr: |
|
expr: |
|
||||||
rate(switchboard_task_executions_total{status="error"}[15m])
|
rate(armature_task_executions_total{status="error"}[15m])
|
||||||
/ rate(switchboard_task_executions_total[15m]) > 0.25
|
/ rate(armature_task_executions_total[15m]) > 0.25
|
||||||
for: 10m
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Task failure rate exceeds 25%"
|
summary: "Task failure rate exceeds 25%"
|
||||||
- alert: SwitchboardNoCompletions
|
- alert: ArmatureNoCompletions
|
||||||
expr: sum(rate(switchboard_completions_total[10m])) == 0
|
expr: sum(rate(armature_completions_total[10m])) == 0
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backup
|
name: {{ .Release.Name }}-backup
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
app.kubernetes.io/component: backup
|
app.kubernetes.io/component: backup
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-data
|
name: {{ .Release.Name }}-data
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- {{ .Values.persistence.accessMode }}
|
- {{ .Values.persistence.accessMode }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: Secret
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-secrets
|
name: {{ .Release.Name }}-secrets
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
|
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
|
||||||
@@ -15,11 +15,11 @@ stringData:
|
|||||||
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
|
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.admin.password }}
|
{{- if .Values.admin.password }}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
|
ARMATURE_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
|
ARMATURE_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.admin.email }}
|
{{- if .Values.admin.email }}
|
||||||
SWITCHBOARD_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
|
ARMATURE_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.storage.s3.accessKey }}
|
{{- if .Values.storage.s3.accessKey }}
|
||||||
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}
|
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: ServiceMonitor
|
kind: ServiceMonitor
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}
|
name: {{ include "armature.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.serviceMonitor.labels }}
|
{{- with .Values.monitoring.serviceMonitor.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "switchboard.selectorLabels" . | nindent 6 }}
|
{{- include "armature.selectorLabels" . | nindent 6 }}
|
||||||
app.kubernetes.io/component: backend
|
app.kubernetes.io/component: backend
|
||||||
endpoints:
|
endpoints:
|
||||||
- port: http
|
- port: http
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Service
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backend
|
name: {{ .Release.Name }}-backend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.backend.labels" . | nindent 4 }}
|
{{- include "armature.backend.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/port: {{ .Values.backend.port | quote }}
|
prometheus.io/port: {{ .Values.backend.port | quote }}
|
||||||
@@ -25,7 +25,7 @@ kind: Service
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-frontend
|
name: {{ .Release.Name }}-frontend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.frontend.labels" . | nindent 4 }}
|
{{- include "armature.frontend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Switchboard Core — Helm values
|
# Armature — Helm values
|
||||||
# helm install switchboard ./chart
|
# helm install armature ./chart
|
||||||
|
|
||||||
# ── Images ─────────────────────────────────
|
# ── Images ─────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
image:
|
image:
|
||||||
repository: git.gobha.me/switchboard/core
|
repository: git.gobha.me/armature/core
|
||||||
tag: "" # defaults to Chart.appVersion
|
tag: "" # defaults to Chart.appVersion
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
replicaCount: 2 # v0.32.0: multi-replica HA
|
replicaCount: 2 # v0.32.0: multi-replica HA
|
||||||
@@ -19,7 +19,7 @@ backend:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image:
|
image:
|
||||||
repository: git.gobha.me/switchboard/core
|
repository: git.gobha.me/armature/core
|
||||||
tag: "" # defaults to Chart.appVersion
|
tag: "" # defaults to Chart.appVersion
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
@@ -41,12 +41,12 @@ database:
|
|||||||
postgres:
|
postgres:
|
||||||
host: postgresql
|
host: postgresql
|
||||||
port: 5432
|
port: 5432
|
||||||
user: switchboard
|
user: armature
|
||||||
password: "" # set via secret
|
password: "" # set via secret
|
||||||
database: switchboard
|
database: armature
|
||||||
sslmode: disable
|
sslmode: disable
|
||||||
# For sqlite: path inside the container (requires persistence)
|
# For sqlite: path inside the container (requires persistence)
|
||||||
sqlitePath: /data/switchboard.db
|
sqlitePath: /data/armature.db
|
||||||
|
|
||||||
# ── Core ───────────────────────────────────
|
# ── Core ───────────────────────────────────
|
||||||
basePath: "" # URL prefix, e.g. "/dev"
|
basePath: "" # URL prefix, e.g. "/dev"
|
||||||
@@ -85,7 +85,7 @@ ingress:
|
|||||||
enabled: true
|
enabled: true
|
||||||
className: traefik
|
className: traefik
|
||||||
annotations: {}
|
annotations: {}
|
||||||
host: switchboard.local
|
host: armature.local
|
||||||
tls:
|
tls:
|
||||||
enabled: false
|
enabled: false
|
||||||
secretName: ""
|
secretName: ""
|
||||||
|
|||||||
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
|
||||||
@@ -103,7 +103,7 @@ done
|
|||||||
|
|
||||||
# ── Test 4: Stop one replica → stale sweep ────
|
# ── Test 4: Stop one replica → stale sweep ────
|
||||||
echo "=== Test 4: Stop node-3, wait for sweep ==="
|
echo "=== Test 4: Stop node-3, wait for sweep ==="
|
||||||
docker compose -f docker-compose-e2e.yml stop switchboard-3
|
docker compose -f docker-compose-e2e.yml stop armature-3
|
||||||
|
|
||||||
echo " waiting ${STALE_WAIT}s for stale sweep..."
|
echo " waiting ${STALE_WAIT}s for stale sweep..."
|
||||||
sleep "$STALE_WAIT"
|
sleep "$STALE_WAIT"
|
||||||
@@ -118,7 +118,7 @@ fi
|
|||||||
|
|
||||||
# ── Test 5: Restart replica → re-registers ────
|
# ── Test 5: Restart replica → re-registers ────
|
||||||
echo "=== Test 5: Restart node-3 ==="
|
echo "=== Test 5: Restart node-3 ==="
|
||||||
docker compose -f docker-compose-e2e.yml start switchboard-3
|
docker compose -f docker-compose-e2e.yml start armature-3
|
||||||
|
|
||||||
# Wait for startup + at least one heartbeat
|
# Wait for startup + at least one heartbeat
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ events {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
upstream switchboard {
|
upstream armature {
|
||||||
server switchboard-1:80;
|
server armature-1:80;
|
||||||
server switchboard-2:80;
|
server armature-2:80;
|
||||||
server switchboard-3:80;
|
server armature-3:80;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket upgrade map
|
# WebSocket upgrade map
|
||||||
@@ -19,7 +19,7 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://switchboard;
|
proxy_pass http://armature;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -28,7 +28,7 @@ http {
|
|||||||
|
|
||||||
# WebSocket endpoint
|
# WebSocket endpoint
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://switchboard;
|
proxy_pass http://armature;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|||||||
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
|
||||||
@@ -83,11 +83,11 @@ authed() {
|
|||||||
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
||||||
|
|
||||||
echo "Building 'old' image from HEAD..."
|
echo "Building 'old' image from HEAD..."
|
||||||
docker build -t switchboard-core:v-old . -q
|
docker build -t armature:v-old . -q
|
||||||
ok "old image built"
|
ok "old image built"
|
||||||
|
|
||||||
echo "Building 'new' image from working tree..."
|
echo "Building 'new' image from working tree..."
|
||||||
docker build -t switchboard-core:v-new . -q
|
docker build -t armature:v-new . -q
|
||||||
ok "new image built"
|
ok "new image built"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -97,7 +97,7 @@ ok "new image built"
|
|||||||
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
|
||||||
|
|
||||||
# Override the build with old image
|
# Override the build with old image
|
||||||
SWITCHBOARD_IMAGE=switchboard-core:v-old \
|
ARMATURE_IMAGE=armature:v-old \
|
||||||
docker compose -f "$COMPOSE_FILE" up postgres -d
|
docker compose -f "$COMPOSE_FILE" up postgres -d
|
||||||
|
|
||||||
# Wait for postgres
|
# Wait for postgres
|
||||||
@@ -107,30 +107,30 @@ sleep 3
|
|||||||
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8081:80 switchboard-core:v-old 2>/dev/null || true
|
-p 8081:80 armature:v-old 2>/dev/null || true
|
||||||
|
|
||||||
docker run -d --name sb-old-2 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-old-2 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8082:80 switchboard-core:v-old 2>/dev/null || true
|
-p 8082:80 armature:v-old 2>/dev/null || true
|
||||||
|
|
||||||
# Start nginx LB
|
# Start nginx LB
|
||||||
docker compose -f "$COMPOSE_FILE" up lb -d
|
docker compose -f "$COMPOSE_FILE" up lb -d
|
||||||
@@ -189,16 +189,16 @@ echo "Starting new replica-1..."
|
|||||||
docker run -d --name sb-new-1 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-new-1 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8081:80 switchboard-core:v-new
|
-p 8081:80 armature:v-new
|
||||||
|
|
||||||
wait_for_health "$R1" "new replica-1"
|
wait_for_health "$R1" "new replica-1"
|
||||||
|
|
||||||
@@ -246,16 +246,16 @@ echo "Starting new replica-2..."
|
|||||||
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8082:80 switchboard-core:v-new
|
-p 8082:80 armature:v-new
|
||||||
|
|
||||||
wait_for_health "$R2" "new replica-2"
|
wait_for_health "$R2" "new replica-2"
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
|||||||
# Build "old" image from last commit (before current changes)
|
# Build "old" image from last commit (before current changes)
|
||||||
echo "Building 'old' image from HEAD commit..."
|
echo "Building 'old' image from HEAD commit..."
|
||||||
git stash -q 2>/dev/null || true
|
git stash -q 2>/dev/null || true
|
||||||
docker build --no-cache -t switchboard-core:v-old . -q
|
docker build --no-cache -t armature:v-old . -q
|
||||||
git stash pop -q 2>/dev/null || true
|
git stash pop -q 2>/dev/null || true
|
||||||
ok "old image built"
|
ok "old image built"
|
||||||
|
|
||||||
# Build "new" image from working tree (with current changes)
|
# Build "new" image from working tree (with current changes)
|
||||||
echo "Building 'new' image from working tree..."
|
echo "Building 'new' image from working tree..."
|
||||||
docker build --no-cache -t core-switchboard-new . -q
|
docker build --no-cache -t armature:v-new . -q
|
||||||
ok "new image built"
|
ok "new image built"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -97,7 +97,7 @@ ok "new image built"
|
|||||||
|
|
||||||
echo -e "\n${YELLOW}═══ Phase 2: Seed Data on Old Version ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 2: Seed Data on Old Version ═══${NC}"
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" up postgres switchboard-old -d
|
docker compose -f "$COMPOSE_FILE" up postgres armature-old -d
|
||||||
wait_for_health "$HOST"
|
wait_for_health "$HOST"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -179,17 +179,17 @@ ok "recorded $PRE_PKG_COUNT installed packages"
|
|||||||
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
|
||||||
|
|
||||||
echo "Stopping old version..."
|
echo "Stopping old version..."
|
||||||
docker compose -f "$COMPOSE_FILE" stop switchboard-old
|
docker compose -f "$COMPOSE_FILE" stop armature-old
|
||||||
ok "old version stopped"
|
ok "old version stopped"
|
||||||
|
|
||||||
echo "Starting new version..."
|
echo "Starting new version..."
|
||||||
docker compose -f "$COMPOSE_FILE" up switchboard-new -d
|
docker compose -f "$COMPOSE_FILE" up armature-new -d
|
||||||
wait_for_health "$HOST"
|
wait_for_health "$HOST"
|
||||||
ok "new version started"
|
ok "new version started"
|
||||||
|
|
||||||
# Check for migration errors in logs
|
# Check for migration errors in logs
|
||||||
echo -e "\n${YELLOW}Checking startup logs...${NC}"
|
echo -e "\n${YELLOW}Checking startup logs...${NC}"
|
||||||
LOGS=$(docker compose -f "$COMPOSE_FILE" logs switchboard-new 2>&1 || echo "")
|
LOGS=$(docker compose -f "$COMPOSE_FILE" logs armature-new 2>&1 || echo "")
|
||||||
if echo "$LOGS" | grep -qi "migration.*fail\|schema.*error\|panic\|fatal"; then
|
if echo "$LOGS" | grep -qi "migration.*fail\|schema.*error\|panic\|fatal"; then
|
||||||
fail "migration errors found in logs"
|
fail "migration errors found in logs"
|
||||||
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5
|
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5
|
||||||
|
|||||||
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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"realm": "switchboard",
|
"realm": "armature",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"registrationAllowed": false,
|
"registrationAllowed": false,
|
||||||
"loginWithEmailAllowed": true,
|
"loginWithEmailAllowed": true,
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
"realm": [
|
"realm": [
|
||||||
{
|
{
|
||||||
"name": "sb-admin",
|
"name": "sb-admin",
|
||||||
"description": "Switchboard admin role"
|
"description": "Armature admin role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sb-user",
|
"name": "sb-user",
|
||||||
"description": "Switchboard regular user role"
|
"description": "Armature regular user role"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
],
|
],
|
||||||
"clients": [
|
"clients": [
|
||||||
{
|
{
|
||||||
"clientId": "switchboard",
|
"clientId": "armature",
|
||||||
"name": "Switchboard Core",
|
"name": "Armature",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"publicClient": false,
|
"publicClient": false,
|
||||||
"secret": "switchboard-secret",
|
"secret": "armature-secret",
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:3000/*",
|
"http://localhost:3000/*",
|
||||||
"http://localhost:8080/*"
|
"http://localhost:8080/*"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"email": "alice@switchboard.test",
|
"email": "alice@armature.test",
|
||||||
"firstName": "Alice",
|
"firstName": "Alice",
|
||||||
"lastName": "Engineer",
|
"lastName": "Engineer",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
],
|
],
|
||||||
"realmRoles": [
|
"realmRoles": [
|
||||||
"sb-user",
|
"sb-user",
|
||||||
"default-roles-switchboard"
|
"default-roles-armature"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"/engineering"
|
"/engineering"
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "bob",
|
"username": "bob",
|
||||||
"email": "bob@switchboard.test",
|
"email": "bob@armature.test",
|
||||||
"firstName": "Bob",
|
"firstName": "Bob",
|
||||||
"lastName": "Lead",
|
"lastName": "Lead",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"realmRoles": [
|
"realmRoles": [
|
||||||
"sb-admin",
|
"sb-admin",
|
||||||
"sb-user",
|
"sb-user",
|
||||||
"default-roles-switchboard"
|
"default-roles-armature"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"/engineering",
|
"/engineering",
|
||||||
|
|||||||
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# docker-compose-e2e.yml — Multi-replica E2E testing (Postgres)
|
# docker-compose-e2e.yml — Multi-replica E2E testing (Postgres)
|
||||||
#
|
#
|
||||||
# Three Switchboard replicas behind an nginx load balancer,
|
# Three Armature replicas behind an nginx load balancer,
|
||||||
# sharing a single Postgres instance. Tests cross-replica
|
# sharing a single Postgres instance. Tests cross-replica
|
||||||
# broadcast via pg_notify and cluster registry (v0.6.0).
|
# broadcast via pg_notify and cluster registry (v0.6.0).
|
||||||
#
|
#
|
||||||
@@ -14,18 +14,18 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard_e2e
|
POSTGRES_DB: armature_e2e
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: e2e-password
|
POSTGRES_PASSWORD: e2e-password
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_e2e"]
|
test: ["CMD-SHELL", "pg_isready -U armature -d armature_e2e"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
switchboard-1:
|
armature-1:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -33,11 +33,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -48,14 +48,14 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-1"
|
CLUSTER_NODE_ID: "node-1"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-1:8080"
|
CLUSTER_ENDPOINT: "http://armature-1:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
|
|
||||||
switchboard-2:
|
armature-2:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -63,11 +63,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -78,14 +78,14 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-2"
|
CLUSTER_NODE_ID: "node-2"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-2:8080"
|
CLUSTER_ENDPOINT: "http://armature-2:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8082:80"
|
- "8082:80"
|
||||||
|
|
||||||
switchboard-3:
|
armature-3:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -93,11 +93,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -108,7 +108,7 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-3"
|
CLUSTER_NODE_ID: "node-3"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-3:8080"
|
CLUSTER_ENDPOINT: "http://armature-3:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -122,6 +122,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- switchboard-1
|
- armature-1
|
||||||
- switchboard-2
|
- armature-2
|
||||||
- switchboard-3
|
- armature-3
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up
|
# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up
|
||||||
#
|
#
|
||||||
# This extends the base docker-compose.yml to add:
|
# This extends the base docker-compose.yml to add:
|
||||||
# - Keycloak IdP with a pre-configured "switchboard" realm
|
# - Keycloak IdP with a pre-configured "armature" realm
|
||||||
# - Switchboard configured with AUTH_MODE=oidc pointing at Keycloak
|
# - Armature configured with AUTH_MODE=oidc pointing at Keycloak
|
||||||
#
|
#
|
||||||
# After startup:
|
# After startup:
|
||||||
# Switchboard: http://localhost:3000
|
# Armature: http://localhost:3000
|
||||||
# Keycloak: http://localhost:8180 (admin / admin)
|
# Keycloak: http://localhost:8180 (admin / admin)
|
||||||
#
|
#
|
||||||
# Pre-configured test users in Keycloak:
|
# Pre-configured test users in Keycloak:
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Override switchboard to use OIDC
|
# Override armature to use OIDC
|
||||||
switchboard:
|
armature:
|
||||||
environment:
|
environment:
|
||||||
AUTH_MODE: oidc
|
AUTH_MODE: oidc
|
||||||
OIDC_ISSUER_URL: http://keycloak:8080/realms/switchboard
|
OIDC_ISSUER_URL: http://keycloak:8080/realms/armature
|
||||||
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/switchboard
|
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/armature
|
||||||
OIDC_CLIENT_ID: switchboard
|
OIDC_CLIENT_ID: armature
|
||||||
OIDC_CLIENT_SECRET: switchboard-secret
|
OIDC_CLIENT_SECRET: armature-secret
|
||||||
OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback
|
OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback
|
||||||
OIDC_AUTO_ACTIVATE: "true"
|
OIDC_AUTO_ACTIVATE: "true"
|
||||||
OIDC_ADMIN_ROLE: sb-admin
|
OIDC_ADMIN_ROLE: sb-admin
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.0
|
image: quay.io/keycloak/keycloak:26.0
|
||||||
container_name: switchboard-keycloak
|
container_name: armature-keycloak
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
- --import-realm
|
- --import-realm
|
||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
KC_HTTP_PORT: "8080"
|
KC_HTTP_PORT: "8080"
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- ./ci/keycloak-realm.json:/opt/keycloak/data/import/switchboard-realm.json:ro
|
- ./ci/keycloak-realm.json:/opt/keycloak/data/import/armature-realm.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
|
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
|
||||||
#
|
#
|
||||||
# Manual usage:
|
# Manual usage:
|
||||||
# docker build -t switchboard-core:v-old .
|
# docker build -t armature:v-old .
|
||||||
# docker compose -f docker-compose-upgrade.yml up postgres switchboard-old -d
|
# docker compose -f docker-compose-upgrade.yml up postgres armature-old -d
|
||||||
# # ... seed data ...
|
# # ... seed data ...
|
||||||
# docker compose -f docker-compose-upgrade.yml stop switchboard-old
|
# docker compose -f docker-compose-upgrade.yml stop armature-old
|
||||||
# docker compose -f docker-compose-upgrade.yml up switchboard-new -d
|
# docker compose -f docker-compose-upgrade.yml up armature-new -d
|
||||||
# # ... verify data ...
|
# # ... verify data ...
|
||||||
# docker compose -f docker-compose-upgrade.yml down -v
|
# docker compose -f docker-compose-upgrade.yml down -v
|
||||||
|
|
||||||
@@ -19,28 +19,28 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard_upgrade
|
POSTGRES_DB: armature_upgrade
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: upgrade-password
|
POSTGRES_PASSWORD: upgrade-password
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_upgrade"]
|
test: ["CMD-SHELL", "pg_isready -U armature -d armature_upgrade"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
switchboard-old:
|
armature-old:
|
||||||
image: switchboard-core:v-old
|
image: armature:v-old
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
DATABASE_URL: postgres://armature:upgrade-password@postgres:5432/armature_upgrade?sslmode=disable
|
||||||
JWT_SECRET: upgrade-jwt-secret
|
JWT_SECRET: upgrade-jwt-secret
|
||||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -56,17 +56,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- upgrade_storage:/data/storage
|
- upgrade_storage:/data/storage
|
||||||
|
|
||||||
switchboard-new:
|
armature-new:
|
||||||
image: core-switchboard-new
|
image: core-armature-new
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
DATABASE_URL: postgres://armature:upgrade-password@postgres:5432/armature_upgrade?sslmode=disable
|
||||||
JWT_SECRET: upgrade-jwt-secret
|
JWT_SECRET: upgrade-jwt-secret
|
||||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
|
|||||||
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"]
|
||||||
@@ -13,20 +13,20 @@
|
|||||||
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
switchboard:
|
armature:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: switchboard-core
|
container_name: armature
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: sqlite
|
DB_DRIVER: sqlite
|
||||||
DATABASE_URL: /data/switchboard.db
|
DATABASE_URL: /data/armature.db
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-for-production}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-for-production}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ARMATURE_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ARMATURE_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Backend Launcher
|
# Armature - Backend Launcher
|
||||||
# ============================================
|
# ============================================
|
||||||
# Runs as an nginx entrypoint.d hook.
|
# Runs as an nginx entrypoint.d hook.
|
||||||
# Starts the Go backend in the background
|
# Starts the Go backend in the background
|
||||||
@@ -20,15 +20,15 @@ else
|
|||||||
-exec sed -i "s|%%BASE_PATH%%||g" {} +
|
-exec sed -i "s|%%BASE_PATH%%||g" {} +
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔀 Starting Switchboard Core backend..."
|
echo "🔀 Starting Armature backend..."
|
||||||
|
|
||||||
# Kill any stale backend from a previous entrypoint run
|
# Kill any stale backend from a previous entrypoint run
|
||||||
pkill -f /usr/local/bin/switchboard 2>/dev/null || true
|
pkill -f /usr/local/bin/armature 2>/dev/null || true
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
|
|
||||||
# Launch Go backend in background (from /app so migrations are found)
|
# Launch Go backend in background (from /app so migrations are found)
|
||||||
cd /app
|
cd /app
|
||||||
/usr/local/bin/switchboard &
|
/usr/local/bin/armature &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready (max 60s)
|
# Wait for backend to be ready (max 60s)
|
||||||
|
|||||||
@@ -184,3 +184,30 @@ Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `pres
|
|||||||
| POST | `/presence/heartbeat` | Update presence status |
|
| POST | `/presence/heartbeat` | Update presence status |
|
||||||
| GET | `/presence` | Query online users |
|
| GET | `/presence` | Query online users |
|
||||||
| GET | `/users/search` | Search 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"}]`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Architecture
|
# Armature — Architecture
|
||||||
|
|
||||||
Switchboard Core is a self-hosted extension platform. It provides identity,
|
Armature is a self-hosted extension platform. It provides identity,
|
||||||
teams, permissions, storage, workflows, notifications, and a package system.
|
teams, permissions, storage, workflows, notifications, and a package system.
|
||||||
Everything else — chat, AI providers, personas, knowledge bases, notes,
|
Everything else — chat, AI providers, personas, knowledge bases, notes,
|
||||||
tools — ships as installable extensions.
|
tools — ships as installable extensions.
|
||||||
@@ -271,12 +271,13 @@ graph TD
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
||||||
(except CM6 via esbuild). IIFE/global-namespace pattern with
|
(except CM6 via esbuild). ES modules loaded via `<script type="module">`.
|
||||||
`sb.register()`/`sb.ns()`.
|
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`
|
The shell provides a two-slot topbar (left title + center slot) that every
|
||||||
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs
|
surface inherits. Extensions use `window.html` and `window.preact` directly.
|
||||||
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image.
|
Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
|
||||||
|
CodeMirror 6) baked into the image.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ Single Docker image: Go binary + migrations + frontend assets + vendor
|
|||||||
libs. Kubernetes deployment with 3-node PG cluster. CI via Gitea Actions
|
libs. Kubernetes deployment with 3-node PG cluster. CI via Gitea Actions
|
||||||
with DaemonSet DinD runners testing both PG and SQLite pipelines.
|
with DaemonSet DinD runners testing both PG and SQLite pipelines.
|
||||||
|
|
||||||
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
|
Registry: `registry.gobha.me:5000/xcaliber/armature`
|
||||||
Namespace: `gobha-ai-chat`
|
Namespace: `gobha-ai-chat`
|
||||||
|
|
||||||
### Cluster Topology
|
### Cluster Topology
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ def on_fire(ctx):
|
|||||||
|
|
||||||
resp = http.post(url, body=payload, headers={
|
resp = http.post(url, body=payload, headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Switchboard-Event": data.get("event_name", "workflow.notify")
|
"X-Armature-Event": data.get("event_name", "workflow.notify")
|
||||||
})
|
})
|
||||||
|
|
||||||
# Log the delivery
|
# Log the delivery
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
## Docker Single-Instance
|
## Docker Single-Instance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/switchboard-core:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
-v switchboard-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
||||||
@@ -22,32 +22,32 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard
|
POSTGRES_DB: armature
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: secretpassword
|
POSTGRES_PASSWORD: secretpassword
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
switchboard:
|
armature:
|
||||||
image: ghcr.io/switchboard-core/switchboard-core:latest
|
image: gobha/armature:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgres://switchboard:secretpassword@postgres:5432/switchboard?sslmode=disable"
|
DATABASE_URL: "postgres://armature:secretpassword@postgres:5432/armature?sslmode=disable"
|
||||||
JWT_SECRET: "change-me-in-production"
|
JWT_SECRET: "change-me-in-production"
|
||||||
ENCRYPTION_KEY: "change-me-in-production"
|
ENCRYPTION_KEY: "change-me-in-production"
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: changeme
|
ARMATURE_ADMIN_PASSWORD: changeme
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
volumes:
|
volumes:
|
||||||
- sb_storage:/data/storage
|
- armature_storage:/data/storage
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_data:
|
pg_data:
|
||||||
sb_storage:
|
armature_storage:
|
||||||
```
|
```
|
||||||
|
|
||||||
## Kubernetes
|
## Kubernetes
|
||||||
@@ -58,7 +58,7 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
|
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
|
||||||
- Mount a PVC at `/data/storage` or configure S3.
|
- Mount a PVC at `/data/storage` or configure S3.
|
||||||
- Store `JWT_SECRET` and `ENCRYPTION_KEY` in Kubernetes Secrets.
|
- Store `JWT_SECRET` and `ENCRYPTION_KEY` in Kubernetes Secrets.
|
||||||
- Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`.
|
- Registry: `registry.gobha.me:5000/xcaliber/armature`.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -70,17 +70,18 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
|
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
|
||||||
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
|
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
|
||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
| `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_BACKEND` | auto | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `BASE_PATH` | | URL prefix (e.g., `/switchboard`) |
|
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
|
||||||
| `LOG_FORMAT` | `text` | `text` or `json` |
|
| `LOG_FORMAT` | `text` | `text` or `json` |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
|
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
|
||||||
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
|
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
|
||||||
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
|
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
|
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
|
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
|
||||||
| `SEED_USERS` | | Dev seed users (ignored in production) |
|
| `SEED_USERS` | | Dev seed users (ignored in production) |
|
||||||
|
|
||||||
### S3 Storage Variables
|
### S3 Storage Variables
|
||||||
@@ -107,14 +108,14 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
**PostgreSQL** (recommended for production):
|
**PostgreSQL** (recommended for production):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require"
|
DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require"
|
||||||
```
|
```
|
||||||
|
|
||||||
**SQLite** (dev, test, edge deployments):
|
**SQLite** (dev, test, edge deployments):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
DATABASE_URL=/data/switchboard.db
|
DATABASE_URL=/data/armature.db
|
||||||
```
|
```
|
||||||
|
|
||||||
Both databases are first-class -- every query compiles and passes tests on both. The driver is auto-detected from `DATABASE_URL` if `DB_DRIVER` is not set.
|
Both databases are first-class -- every query compiles and passes tests on both. The driver is auto-detected from `DATABASE_URL` if `DB_DRIVER` is not set.
|
||||||
|
|||||||
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
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Switchboard-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY.
|
Armature-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY.
|
||||||
|
|
||||||
## Principle
|
## Principle
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ Pre-MVP: fold `CREATE UNLOGGED TABLE` into existing schema initialization. No ne
|
|||||||
|
|
||||||
### Single-Node Behavior
|
### Single-Node Behavior
|
||||||
|
|
||||||
When only one node is registered, the system behaves identically to pre-cluster switchboard-core. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required.
|
When only one node is registered, the system behaves identically to pre-cluster armature. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required.
|
||||||
|
|
||||||
### Health Endpoint Integration
|
### Health Endpoint Integration
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
@@ -172,17 +172,17 @@ their own `node-N.key`.
|
|||||||
|
|
||||||
### Cert Provisioning Tooling
|
### Cert Provisioning Tooling
|
||||||
|
|
||||||
A shell script (`scripts/switchboard-ca.sh`) wrapping `openssl` is the
|
A shell script (`scripts/armature-ca.sh`) wrapping `openssl` is the
|
||||||
KISS path. No new binary, no new dependency. Three commands:
|
KISS path. No new binary, no new dependency. Three commands:
|
||||||
|
|
||||||
```
|
```
|
||||||
switchboard-ca init
|
armature-ca init
|
||||||
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
||||||
|
|
||||||
switchboard-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
armature-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
||||||
→ generates node-1.crt + node-1.key in ./nodes/
|
→ generates node-1.crt + node-1.key in ./nodes/
|
||||||
|
|
||||||
switchboard-ca issue-user --cn jeff [--email jeff@example.com]
|
armature-ca issue-user --cn jeff [--email jeff@example.com]
|
||||||
→ generates jeff.crt + jeff.key in ./users/
|
→ generates jeff.crt + jeff.key in ./users/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ The new code is:
|
|||||||
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
||||||
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
||||||
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
||||||
| `scripts/switchboard-ca.sh` | Cert provisioning wrapper |
|
| `scripts/armature-ca.sh` | Cert provisioning wrapper |
|
||||||
|
|
||||||
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
||||||
clarity. No behavioral changes to the proxy provider.
|
clarity. No behavioral changes to the proxy provider.
|
||||||
|
|||||||
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/switchboard-core:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
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)
|
# Install ALL packages (everything in the image)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="*" \
|
-e BUNDLED_PACKAGES="*" \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# Install specific packages only
|
# Install specific packages only
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
||||||
ghcr.io/switchboard-core/switchboard-core: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.
|
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
|
```bash
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SKIP_BUNDLED_PACKAGES=true \
|
-e SKIP_BUNDLED_PACKAGES=true \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Bundle Directory
|
### Custom Bundle Directory
|
||||||
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
|
|||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
||||||
-v /host/packages:/custom/packages \
|
-v /host/packages:/custom/packages \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Builder Image
|
## 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.
|
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/builder:latest
|
docker pull gobha/armature-builder:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### What It Caches
|
### What It Caches
|
||||||
@@ -117,16 +117,16 @@ docker pull ghcr.io/switchboard-core/builder:latest
|
|||||||
Reference the builder image as a base stage in your Dockerfile:
|
Reference the builder image as a base stage in your Dockerfile:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM ghcr.io/switchboard-core/builder:latest AS builder
|
FROM gobha/armature-builder:latest AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/ .
|
COPY server/ .
|
||||||
RUN go build -ldflags="-s -w" -o /bin/switchboard .
|
RUN go build -ldflags="-s -w" -o /bin/armature .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building Locally
|
### Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f Dockerfile.builder -t switchboard-builder .
|
docker build -f Dockerfile.builder -t armature-builder .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom Build Guide
|
## Custom Build Guide
|
||||||
@@ -135,7 +135,7 @@ docker build -f Dockerfile.builder -t switchboard-builder .
|
|||||||
|
|
||||||
1. Create your package in `packages/your-package/` with a `manifest.json`
|
1. Create your package in `packages/your-package/` with a `manifest.json`
|
||||||
2. Build all packages: `cd packages && bash build.sh all`
|
2. Build all packages: `cd packages && bash build.sh all`
|
||||||
3. Build the Docker image: `docker build -t my-switchboard .`
|
3. Build the Docker image: `docker build -t my-armature .`
|
||||||
|
|
||||||
The Dockerfile automatically builds all packages in the `packages/` directory and bundles them into the production image.
|
The Dockerfile automatically builds all packages in the `packages/` directory and bundles them into the production image.
|
||||||
|
|
||||||
@@ -148,14 +148,14 @@ To exclude specific packages from the bundle, either:
|
|||||||
### Forking for Custom Builds
|
### Forking for Custom Builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/switchboard-core/switchboard-core.git
|
git clone https://github.com/gobha/armature.git
|
||||||
cd switchboard-core
|
cd armature
|
||||||
|
|
||||||
# Add/modify packages
|
# Add/modify packages
|
||||||
cp -r my-extension packages/my-extension/
|
cp -r my-extension packages/my-extension/
|
||||||
|
|
||||||
# Build with builder image for faster compilation
|
# Build with builder image for faster compilation
|
||||||
docker build -t my-switchboard .
|
docker build -t my-armature .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
@@ -172,7 +172,7 @@ docker build -t my-switchboard .
|
|||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
||||||
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3` |
|
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `BASE_PATH` | | URL prefix (e.g. `/switchboard`) |
|
| `BASE_PATH` | | URL prefix (e.g. `/armature`) |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
||||||
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
|
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
|
||||||
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
||||||
@@ -186,16 +186,16 @@ PostgreSQL is recommended for production. SQLite is suitable for single-instance
|
|||||||
```bash
|
```bash
|
||||||
# PostgreSQL (recommended)
|
# PostgreSQL (recommended)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require" \
|
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# SQLite (evaluation only)
|
# SQLite (evaluation only)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e DB_DRIVER=sqlite \
|
-e DB_DRIVER=sqlite \
|
||||||
-v switchboard-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
@@ -204,12 +204,12 @@ Object storage is required for file uploads and package asset extraction.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PVC (auto-detected if path is writable)
|
# PVC (auto-detected if path is writable)
|
||||||
docker run -v switchboard-storage:/data/storage ...
|
docker run -v armature-storage:/data/storage ...
|
||||||
|
|
||||||
# S3-compatible (MinIO, AWS S3, Ceph)
|
# S3-compatible (MinIO, AWS S3, Ceph)
|
||||||
docker run \
|
docker run \
|
||||||
-e STORAGE_BACKEND=s3 \
|
-e STORAGE_BACKEND=s3 \
|
||||||
-e S3_BUCKET=switchboard \
|
-e S3_BUCKET=armature \
|
||||||
-e S3_ENDPOINT=https://minio.corp:9000 \
|
-e S3_ENDPOINT=https://minio.corp:9000 \
|
||||||
-e S3_ACCESS_KEY=... \
|
-e S3_ACCESS_KEY=... \
|
||||||
-e S3_SECRET_KEY=... \
|
-e S3_SECRET_KEY=... \
|
||||||
|
|||||||
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 |
|
| `icon` | no | Emoji icon for sidebar/menu |
|
||||||
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
|
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
|
||||||
| `auth` | no | `authenticated` (default) or `public` |
|
| `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_routes` | no | Array of `{method, path}` for extension HTTP endpoints |
|
||||||
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
|
||||||
| `db_tables` | no | Table definitions (see below) |
|
| `db_tables` | no | Table definitions (see below) |
|
||||||
| `settings` | no | User-configurable settings schema |
|
| `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 |
|
| `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 |
|
| `schema_version` | no | Integer for additive schema migrations |
|
||||||
|
|
||||||
## db_tables Schema
|
## 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
|
```json
|
||||||
"db_tables": {
|
"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.
|
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 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 |
|
## config_section — Settings Panel Injection
|
||||||
|--------|-----------|-----|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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
|
## 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
|
## 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`.
|
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
|
```bash
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
@@ -20,10 +20,10 @@ docker compose down -v
|
|||||||
|
|
||||||
## First Boot
|
## First Boot
|
||||||
|
|
||||||
On first start, Switchboard Core will:
|
On first start, Armature will:
|
||||||
|
|
||||||
1. Run database migrations (SQLite by default in compose).
|
1. Run database migrations (SQLite by default in compose).
|
||||||
2. Create the admin user from `SWITCHBOARD_ADMIN_USERNAME` / `SWITCHBOARD_ADMIN_PASSWORD` env vars.
|
2. Create the admin user from `ARMATURE_ADMIN_USERNAME` / `ARMATURE_ADMIN_PASSWORD` env vars.
|
||||||
3. Auto-install the curated default package set (notes, chat-core, dashboard, workflow demos, etc.).
|
3. Auto-install the curated default package set (notes, chat-core, dashboard, workflow demos, etc.).
|
||||||
|
|
||||||
No manual setup steps are required.
|
No manual setup steps are required.
|
||||||
@@ -67,8 +67,8 @@ You can also upload `.pkg` archives through the Admin > Packages page.
|
|||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
||||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
|
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
|
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
|
||||||
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
|
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
@@ -77,7 +77,7 @@ You can also upload `.pkg` archives through the Admin > Packages page.
|
|||||||
## From Source
|
## From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> && cd switchboard-core
|
git clone <repo-url> && cd armature
|
||||||
cp server/.env.example server/.env # edit DB credentials
|
cp server/.env.example server/.env # edit DB credentials
|
||||||
cd server && go run .
|
cd server && go run .
|
||||||
# Backend on http://localhost:8080
|
# Backend on http://localhost:8080
|
||||||
|
|||||||
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Package Format
|
# Package Format
|
||||||
|
|
||||||
Switchboard packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
|
Armature packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
|
||||||
|
|
||||||
## ZIP Structure
|
## ZIP Structure
|
||||||
|
|
||||||
@@ -60,16 +60,140 @@ Only `manifest.json` is required. All other directories are optional and include
|
|||||||
| `description` | Short description |
|
| `description` | Short description |
|
||||||
| `icon` | Emoji for sidebar/menu display |
|
| `icon` | Emoji for sidebar/menu display |
|
||||||
| `author` | Package author |
|
| `author` | Package author |
|
||||||
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
|
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
|
||||||
| `auth` | `authenticated` or `public` |
|
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
|
||||||
| `layout` | Surface layout mode (e.g., `single`) |
|
| `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`) |
|
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
||||||
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
||||||
| `db_tables` | Table definitions with columns and indexes |
|
| `db_tables` | Table definitions with columns and indexes |
|
||||||
| `settings` | User-configurable settings with type, label, description, default |
|
| `settings` | User-configurable settings with type, label, description, default |
|
||||||
| `hooks` | Event bus subscription patterns |
|
| `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 |
|
| `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
|
## Package Lifecycle
|
||||||
|
|
||||||
@@ -125,6 +249,6 @@ To bundle custom packages into a Docker image:
|
|||||||
|
|
||||||
1. Place your package in `packages/your-package/` with a `manifest.json`.
|
1. Place your package in `packages/your-package/` with a `manifest.json`.
|
||||||
2. Run `cd packages && bash build.sh all`.
|
2. Run `cd packages && bash build.sh all`.
|
||||||
3. Build the image: `docker build -t my-switchboard .`
|
3. Build the image: `docker build -t my-armature .`
|
||||||
|
|
||||||
The Dockerfile builds all packages and copies them into the bundled packages directory.
|
The Dockerfile builds all packages and copies them into the bundled packages directory.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ The registry is a static JSON file matching the `RegistryResponse` struct:
|
|||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "Markdown notes with backlinks and graph view",
|
"description": "Markdown notes with backlinks and graph view",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"download_url": "https://cdn.example.com/pkg/notes.pkg",
|
"download_url": "https://cdn.example.com/pkg/notes.pkg",
|
||||||
|
|||||||
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`).
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Build Your First Browser Extension
|
# Build Your First Browser Extension
|
||||||
|
|
||||||
This tutorial walks through building a browser extension that renders custom
|
This tutorial walks through building a browser extension that renders custom
|
||||||
code blocks, modeled on the CSV Table Viewer that ships with Switchboard.
|
code blocks, modeled on the CSV Table Viewer that ships with Armature.
|
||||||
|
|
||||||
**Prerequisites:** A running Switchboard instance, a text editor, and `zip`.
|
**Prerequisites:** A running Armature instance, a text editor, and `zip`.
|
||||||
|
|
||||||
## Step 1: Create the Directory
|
## Step 1: Create the Directory
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
|
|||||||
},
|
},
|
||||||
render(lang, code, container) {
|
render(lang, code, container) {
|
||||||
container.innerHTML =
|
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">' +
|
'border:1px solid var(--border);border-radius:8px">' +
|
||||||
'<strong>Demo:</strong> ' + code +
|
'<strong>Demo:</strong> ' + code +
|
||||||
'</div>';
|
'</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,
|
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,
|
`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;
|
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.
|
and `var(--border)` to follow the active theme.
|
||||||
|
|
||||||
## Step 4: Package It
|
## 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 |