30 Commits

Author SHA1 Message Date
98fd3eb3e6 Feat v0.8.5 extension composability (#72)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 52s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 11:33:47 +00:00
3c403dd884 Feat v0.8.4 docs surface fix (#71)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m53s
CI/CD / test-sqlite (push) Successful in 2m56s
CI/CD / build-and-deploy (push) Successful in 1m18s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 10:43:13 +00:00
190905b3e6 Feat v0.8.3 vector column (#70)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-sqlite (push) Successful in 2m59s
CI/CD / test-go-pg (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m14s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 09:41:32 +00:00
00ef970163 Feat v0.8.2 capability negotiation (#69)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 30s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 09:14:00 +00:00
435f972ded Feat v0.8.1 workspace module (#68)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 36s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 00:38:40 +00:00
694779fac6 Feat v0.8.0 files module (#67)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 1m20s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 00:18:05 +00:00
3b74774077 Feat v0.7.12 batch exec (#66)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m27s
CI/CD / test-frontend (push) Successful in 5s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 23:57:46 +00:00
c2d52f50c5 Feat v0.7.11 query http ergonomics (#65)
All checks were successful
CI/CD / test-frontend (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m53s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 1m13s
CI/CD / detect-changes (push) Successful in 3s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 23:32:17 +00:00
f06c6c954b Feat v0.7.10 workflow handoff (#64)
All checks were successful
CI/CD / test-go-pg (push) Successful in 2m54s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m47s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 23:16:19 +00:00
a9cf71b76d Feat v0.7.9 workflow independence (#63)
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 22:31:47 +00:00
3cdfdcf943 Feat v0.7.8 bug fixes admin gaps (#62)
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 21:36:34 +00:00
e4f0bdbd36 Feat v0.7.7 api tokens ext permissions (#61)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 19:11:47 +00:00
e02b13dc12 Feat v0.7.6 code hygiene (#60)
All checks were successful
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 17:39:41 +00:00
5e830c04de Feat v0.7.5 e2e ci gate (#59)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m50s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 35s
2026-04-02 17:02:35 +00:00
a7e38bc72a Feat v0.7.4 docs surface work (#58)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 14:46:54 +00:00
32e4d8725c Feat v0.7.3 extension shell migration (#57)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 27s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 13:07:59 +00:00
d6c7b21713 Feat v0.7.2 package runners ci gate (#56)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m39s
CI/CD / test-sqlite (push) Successful in 2m55s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 12:10:57 +00:00
829caa3b20 Feat v0.7.1 surface runner framework (#55)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 1m26s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 23:01:38 +00:00
e916ed41ea V0.7.0 shell contract (#54)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / build-and-deploy (push) Successful in 1m5s
2026-04-01 20:19:45 +00:00
1236220302 Feat v0.6.18 ci bundled packages (#53)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m37s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:49:01 +00:00
e7d1b53ebf Feat v0.6.17 bugfixes (#52)
Some checks failed
CI/CD / detect-changes (push) Successful in 16s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Failing after 2m54s
CI/CD / test-sqlite (push) Failing after 3m7s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:36:43 +00:00
ff19a1b4d3 Feat v0.6.16 usability survey gate (#51)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 49s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:52:14 +00:00
d9802df2af Feat v0.6.15 user display audit (#50)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m10s
CI/CD / build-and-deploy (push) Successful in 1m31s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:19:48 +00:00
c9b9e68c18 Feat v0.6.14 visual polish (#49)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m42s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m8s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 13:25:04 +00:00
3af62a9cc5 Feat v0.6.13 responsive spacing (#48)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 1m33s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 12:26:28 +00:00
221ae94f4f Feat v0.6.12 css isolation (#47)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m47s
CI/CD / build-and-deploy (push) Successful in 1m46s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:58:39 +00:00
786bc92768 Feat v0.6.11 css dedup (#46)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 1m17s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:18:28 +00:00
ca3f845c34 Feat v0.6.10 viewport foundation (#45)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / test-go-pg (push) Successful in 3m14s
CI/CD / build-and-deploy (push) Successful in 1m44s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 10:42:44 +00:00
617d81e7d4 Feat v0.6.9 cookie fix roadmap (#44)
Some checks failed
CI/CD / detect-changes (push) Successful in 19s
CI/CD / test-frontend (push) Successful in 27s
CI/CD / test-go-pg (push) Failing after 2m46s
CI/CD / test-sqlite (push) Successful in 3m24s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 09:41:59 +00:00
680ec3b897 Feat rebrand armature (#43)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00
661 changed files with 27206 additions and 14958 deletions

View File

@@ -1,16 +1,18 @@
# .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).
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
#
# Pipeline:
# 0. Detect changes (path-based gating for all downstream jobs)
# 1a. Frontend tests — skipped if only BE/docs changed
# 1a. Frontend tests — skipped if only BE/docs/packages changed
# 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled)
# 1c. Go test (PG) — PG store + handlers against Postgres (race-enabled)
# 2. Build + Deploy — skipped if docs-only change
# 1d. Test runners — re-enabled v0.7.5 (any code change triggers)
# 1e. E2E smoke — Playwright navigation smoke test against every surface
# 2. Build + Deploy — always runs (docs are served in-app)
#
# Test coverage mapping (no package tested by zero jobs):
# Unit packages (auto-discovered) → test-sqlite (race)
@@ -23,28 +25,30 @@
# Path gating rules:
# src/, src/editor/ → frontend tests
# server/, scripts/db-* → backend tests (PG + SQLite)
# packages/ → test-runners + e2e-smoke
# Dockerfile*, k8s/, .gitea/ → all tests (infra change)
# docs/, *.mdskip all tests + deploy
# VERSION, scripts/* → frontend + backend tests
# ci/ infra (CI scripts)
# docs/, *.md → build-and-deploy only (docs served in-app)
# VERSION, scripts/* → build-and-deploy only (no tests)
# Tags (v*) → always full pipeline
#
# Deployment mapping (single domain, path-based):
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
# Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only)
# Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only)
# PR → FE + BE :dev → armature.DOMAIN/dev/ (DB wipe + fresh schema)
# Push to main → FE + BE :test → armature.DOMAIN/test/ (migrate only)
# Tag v* → FE + BE :latest → armature.DOMAIN/ (migrate only)
# → Unified :latest → Docker Hub (not deployed)
#
# Database lifecycle:
# CI: bootstrap (admin creds) → create DB + role + extensions
# CI: dev wipe (app creds) → drop tables for fresh install test
# 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:
# - Bootstrap uses IF NOT EXISTS (idempotent)
# - Dev wipe REFUSES to run on databases not ending in _dev
# - 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:
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
@@ -58,7 +62,7 @@
# Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_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)
# PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing)
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
@@ -88,8 +92,8 @@ env:
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
IMAGE: ${{ vars.REGISTRY }}/switchboard/core
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/switchboard-core' }}
IMAGE: ${{ vars.REGISTRY }}/armature
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/armature' }}
jobs:
# ── Stage 0: Detect Changed Paths ──────────────
@@ -100,6 +104,7 @@ jobs:
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
packages: ${{ steps.filter.outputs.packages }}
infra: ${{ steps.filter.outputs.infra }}
docs_only: ${{ steps.filter.outputs.docs_only }}
steps:
@@ -137,7 +142,7 @@ jobs:
echo "${CHANGED}" | sed 's/^/ /'
# Classify
FE=false; BE=false; INFRA=false; DOCS=false; OTHER=false
FE=false; BE=false; PKG=false; INFRA=false; DOCS=false; OTHER=false
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
@@ -145,19 +150,23 @@ jobs:
FE=true ;;
server/*|scripts/db-*)
BE=true ;;
packages/*)
PKG=true ;;
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
INFRA=true ;;
docs/*|*.md|CHANGELOG.md|LICENSE)
DOCS=true ;;
VERSION|scripts/*)
FE=true; BE=true ;;
DOCS=true ;; # deploy-only — scripts/db-* already matched as BE above
ci/*)
INFRA=true ;;
*)
OTHER=true ;;
esac
done <<< "${CHANGED}"
# Docs-only: only docs changed, nothing else
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$PKG" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
DOCS_ONLY=true
else
DOCS_ONLY=false
@@ -165,6 +174,7 @@ jobs:
echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
echo "backend=${BE}" >> "$GITHUB_OUTPUT"
echo "packages=${PKG}" >> "$GITHUB_OUTPUT"
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
@@ -172,6 +182,7 @@ jobs:
echo "━━━ Change Detection ━━━"
echo " frontend: ${FE}"
echo " backend: ${BE}"
echo " packages: ${PKG}"
echo " infra: ${INFRA}"
echo " docs_only: ${DOCS_ONLY}"
@@ -319,7 +330,7 @@ jobs:
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
APP_USER: ${{ secrets.POSTGRES_USER }}
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DB_NAME: switchboard_core_ci
DB_NAME: armature_ci
run: |
echo "━━━ CI Test Database Setup ━━━"
# Create DB for Go integration tests (admin creds)
@@ -363,23 +374,93 @@ jobs:
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
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"
# ── Stage 1d: Surface Test Runners ──────────
# Boots the server in Docker, runs all installed test-runner packages
# via Playwright, and asserts zero failures.
# See: docker-compose.ci.yml, ci/surface-test-driver.js
#
# Runs when: backend, frontend, or packages changed (runners test all tiers).
# Skipped when: only docs changed.
test-runners:
runs-on: ubuntu-latest
needs: [detect-changes]
# DISABLED: Playwright auth bypass not working in Docker (v0.7.5).
# Re-enable once headless cookie injection is solved.
if: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run surface tests (compose)
env:
BUNDLED_PACKAGES: '*'
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_EMAIL: admin@test.local
run: |
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
--abort-on-container-exit \
--exit-code-from test-runner
- name: Teardown
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
# ── Stage 1e: E2E Smoke Test ───────────────
# Boots the server in Docker, runs Playwright navigation smoke
# test against every surface. Asserts topbar, no JS errors.
# Screenshots saved as artifacts on failure.
# See: docker-compose.ci.yml (e2e-smoke service), ci/e2e-smoke-driver.js
e2e-smoke:
runs-on: ubuntu-latest
needs: [detect-changes]
# DISABLED: Playwright auth bypass not working in Docker (v0.7.5).
# Re-enable once headless cookie injection is solved.
if: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run E2E smoke tests (compose)
env:
BUNDLED_PACKAGES: '*'
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_EMAIL: admin@test.local
run: |
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
--abort-on-container-exit \
--exit-code-from e2e-smoke
- name: Collect screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: /tmp/e2e-screenshots/
retention-days: 7
- name: Teardown
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
# ── Stage 2: Build, Database, Deploy ─────────
#
# Depends on all test jobs. Skipped jobs (due to path gating)
# are treated as successful — no blocking.
#
# Skipped entirely for docs-only changes (nothing to build).
# Always runs — docs are served in-app by the Docs surface.
build-and-deploy:
runs-on: ubuntu-latest
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite]
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite, test-runners, e2e-smoke]
# Run unless: a needed job failed or the workflow was cancelled.
# Skipped test jobs (path-gated) are fine — they don't block.
# Always deploys — docs are served in-app, VERSION needs a build.
if: |
!cancelled() && !failure() &&
needs.detect-changes.outputs.docs_only != 'true'
!cancelled() && !failure()
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -395,9 +476,9 @@ jobs:
- name: Determine environment
id: setup
run: |
# All environments share one host: switchboard.DOMAIN
# All environments share one host: armature.DOMAIN
# Environments are separated by path prefix (BASE_PATH)
DEPLOY_HOST="switchboard.${DOMAIN}"
DEPLOY_HOST="armature.${DOMAIN}"
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
@@ -406,13 +487,14 @@ jobs:
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
echo "BASE_PATH=/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 "REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=*" >> "$GITHUB_OUTPUT"
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
VERSION="${{ gitea.ref_name }}"
@@ -422,13 +504,14 @@ jobs:
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
echo "BASE_PATH=" >> "$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 "REPLICAS=2" >> "$GITHUB_OUTPUT"
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
else
@@ -437,13 +520,14 @@ jobs:
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
echo "BASE_PATH=/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 "REPLICAS=1" >> "$GITHUB_OUTPUT"
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
fi
@@ -579,14 +663,17 @@ jobs:
# ── Push to Docker Hub (release only) ────────
- name: Push to Docker Hub
if: steps.setup.outputs.is_release == 'true'
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
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 }}
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
if [[ -n "${DH_TOKEN}" ]]; then
echo "${DH_TOKEN}" | docker login -u "${DH_USER}" --password-stdin
docker push ${DOCKERHUB_IMAGE}:latest
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
echo "✓ Pushed to Docker Hub"
else
echo "⚠ Docker Hub credentials not configured, skipping push"
fi
@@ -600,31 +687,31 @@ jobs:
env:
PG_USER: ${{ secrets.POSTGRES_USER }}
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
SW_ADMIN_USER: ${{ secrets.ARMATURE_ADMIN_USERNAME }}
SW_ADMIN_PASS: ${{ secrets.ARMATURE_ADMIN_PASSWORD }}
SW_ADMIN_EMAIL: ${{ secrets.ARMATURE_ADMIN_EMAIL }}
SEED_USERS: ${{ vars.SEED_USERS }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
run: |
kubectl create secret generic switchboard-db-credentials \
kubectl create secret generic armature-db-credentials \
--namespace=${NAMESPACE} \
--from-literal=POSTGRES_USER="${PG_USER}" \
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-admin \
kubectl create secret generic armature-admin \
--namespace=${NAMESPACE} \
--from-literal=username="${SW_ADMIN_USER}" \
--from-literal=password="${SW_ADMIN_PASS}" \
--from-literal=email="${SW_ADMIN_EMAIL}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-seed-users \
kubectl create secret generic armature-seed-users \
--namespace=${NAMESPACE} \
--from-literal=users="${SEED_USERS}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-encryption \
kubectl create secret generic armature-encryption \
--namespace=${NAMESPACE} \
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
@@ -639,7 +726,7 @@ jobs:
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
S3_PREFIX: ${{ vars.S3_PREFIX }}
run: |
kubectl create secret generic switchboard-s3 \
kubectl create secret generic armature-s3 \
--namespace=${NAMESPACE} \
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
--from-literal=S3_BUCKET="${S3_BUCKET}" \
@@ -667,13 +754,14 @@ jobs:
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
run: |
# Render PVC first (must exist before backend references it)
if [[ -n "${STORAGE_CLASS}" ]]; then
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
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/ingress.yaml > /tmp/ingress.yaml
@@ -687,7 +775,7 @@ jobs:
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
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.
# 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,
# which kills all routes for this path prefix.
if [[ "${MW_READY}" == "true" ]]; then
MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd"
kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \
MW_REF="${NAMESPACE}-armature-retry${DEPLOY_SUFFIX}@kubernetescrd"
kubectl annotate ingress "armature${DEPLOY_SUFFIX}" \
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
--namespace="${NAMESPACE}" --overwrite
echo " ✓ Ingress annotated with retry middleware"
@@ -718,20 +806,20 @@ jobs:
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
run: |
kubectl rollout restart deployment/switchboard${SUFFIX} -n ${NAMESPACE}
kubectl rollout status deployment/switchboard${SUFFIX} -n ${NAMESPACE} --timeout=180s
kubectl rollout restart deployment/armature${SUFFIX} -n ${NAMESPACE}
kubectl rollout status deployment/armature${SUFFIX} -n ${NAMESPACE} --timeout=180s
echo ""
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
echo "✅ Healthy: ${READY} pods ready"
else
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
fi

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# Contributing to Switchboard Core
# Contributing to Armature
## Development Setup
@@ -8,8 +8,8 @@
```sh
cd server
go build -o switchboard-core .
DB_DRIVER=sqlite DATABASE_URL=/tmp/switchboard.db ./switchboard-core
go build -o armature .
DB_DRIVER=sqlite DATABASE_URL=/tmp/armature.db ./armature
```
**Docker (recommended):**

View File

@@ -1,5 +1,5 @@
# ============================================
# Switchboard Core — Unified Dockerfile
# Armature — Unified Dockerfile
# ============================================
# Stage 1: Build Go backend
# Stage 2: Download JS vendor libs (marked, DOMPurify)
@@ -20,7 +20,7 @@ COPY server/go.mod server/go.sum* ./
COPY server/ .
COPY VERSION ./
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 ─────────────────
FROM node:20-alpine AS vendor
@@ -70,7 +70,7 @@ FROM nginx:1-alpine
RUN apk add --no-cache bash git
# 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
# Frontend static files

View File

@@ -1,5 +1,5 @@
# ============================================
# Switchboard Core — Builder Image
# Armature — Builder Image
# ============================================
# Pre-caches Go modules and Node dependencies
# for faster custom builds. Use as a base in
@@ -7,12 +7,12 @@
# download on every build.
#
# Usage:
# FROM ghcr.io/switchboard-core/builder:latest AS go-builder
# FROM ghcr.io/armature/builder:latest AS go-builder
# COPY server/ /app/
# RUN cd /app && go build -o /bin/switchboard .
# RUN cd /app && go build -o /bin/armature .
#
# Or build this image locally:
# docker build -f Dockerfile.builder -t switchboard-builder .
# docker build -f Dockerfile.builder -t armature-builder .
# ============================================
# ── 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
LABEL org.opencontainers.image.title="Switchboard Core Builder"
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Switchboard Core builds"
LABEL org.opencontainers.image.title="Armature Builder"
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Armature builds"

View File

@@ -1,11 +1,11 @@
# Switchboard Core
# Armature
A self-hosted extension platform. Identity, teams, permissions, workflows,
and a package system. Everything else ships as installable extensions.
## 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,
workflow orchestration, notifications, and a package installer. It does not
include AI chat, providers, personas, or any domain-specific features —
@@ -27,7 +27,7 @@ docker compose up --build
# → http://localhost:3000 (admin/admin)
# 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
cd server && go run .
# → http://localhost:8080

View File

@@ -1,170 +1,250 @@
# Switchboard Core — Roadmap
# Armature — Roadmap
## Current: v0.6.7 — Native mTLS
## Current: v0.9.x — Workflow Redesign
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension.
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else
is an extension.
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
Audit log · Notifications · Scheduled tasks
**Completed history:** v0.2.xv0.5.x fully documented in `CHANGELOG.md`.
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
workflow engine (multi-stage, team roles, signoff gate, public entry, SLA),
package distribution, Notes surface (CM6, folders, tags, backlinks, graph),
realtime primitive, Chat surface (chat-core library + surface + polish),
upgrade test harness, cluster registry + HA.
Audit log · Notifications · Scheduled tasks · Cluster registry + HA ·
Extension composability (slots/contributes/cross-package calls)
---
## v0.6.0 — MVP
## Completed — v0.6.x through v0.8.x
Extension, communication, and operations tracks converge. First
externally usable release.
All completed work is documented in `CHANGELOG.md`.
Design docs: `docs/DESIGN-cluster-registry.md` — PG-backed cluster registry and self-assembling mesh.
### v0.6.x — MVP + Hardening
### v0.6.0 — Cluster Registry + HA
| Version | Title |
|---------|-------|
| v0.6.0 | Cluster Registry + HA |
| v0.6.1 | Backup/Restore + Docs |
| v0.6.2 | Docs Polish + Dynamic OpenAPI |
| v0.6.3 | Dead Code Sweep + Registry Fix |
| v0.6.4 | Admin Health/Metrics + Cluster Merge |
| v0.6.5 | Renderer Pipeline + Docs Rewrite |
| v0.6.6 | Final Hardening |
| v0.6.7 | Native mTLS |
| v0.6.8 | Rebrand + Cookie Fix |
| v0.6.9 | Session Lifetime Config |
| v0.6.10 | Viewport Foundation |
| v0.6.11 | CSS Deduplication |
| v0.6.12 | Extension CSS Isolation |
| v0.6.13 | Responsive & Spacing |
| v0.6.14 | Visual Polish |
| v0.6.15 | User Display Audit |
| v0.6.16 | Usability Survey Gate |
| v0.6.17 | Bug Fixes & Welcome |
| v0.6.18 | CI Bundle Wiring |
PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/NOTIFY` replaces etcd/Consul/Redis for homelab-to-small-team scale.
### v0.7.x — Test Infrastructure + Quality Gate
| Step | Status | Description |
|------|--------|-------------|
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. |
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. |
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). |
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. |
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. |
| LISTEN/NOTIFY routing | ✅ | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). |
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. |
| Admin cluster dashboard | ✅ | `cluster-dashboard` surface package renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. Auto-refresh every 10s. |
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. |
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). |
| Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. |
| Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. |
| Version | Title |
|---------|-------|
| v0.7.0 | Shell Contract + Surface Audit + Rebrand |
| v0.7.1 | Surface Runner Framework |
| v0.7.2 | Package Runners + CI Gate |
| v0.7.3 | Extension Shell Migration |
| v0.7.4 | Documentation + Surface Work |
| v0.7.5 | Headless E2E + CI Gate |
| v0.7.6 | Code Hygiene + Test Coverage |
| v0.7.7 | API Tokens + Extension Permissions |
| v0.7.8 | Bug Fixes & Admin Gaps |
| v0.7.9 | Workflow Independence Audit |
| v0.7.10 | Workflow Handoff + Assignment UI |
| v0.7.11 | Query & HTTP Ergonomics |
| v0.7.12 | Concurrent Execution Primitive |
### v0.6.1 — Backup/Restore + Documentation
### v0.8.x — Storage Primitives + Composability (Kernel Complete)
| Step | Status | Description |
|------|--------|-------------|
| Backup handler | ✅ | `POST /api/v1/admin/backup` streams `.swb` ZIP (JSONL core + ext_data tables + package assets). `POST /api/v1/admin/restore` wipes DB and restores from archive. Dialect-neutral (SQLite + Postgres). |
| Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
| Docs surface | ✅ | Builtin surface at `/docs/:section`. Sidebar navigation, client-side markdown renderer. 5 new docs: Getting Started, Extension Guide, API Reference, Deployment, Package Format. |
| Tests | ✅ | 6 handler tests (basic backup, ext_data backup, round-trip restore, schema mismatch, dump/restore table, list empty). E2E script `ci/e2e-backup-test.sh`. |
### v0.6.2 — Docs Polish + Dynamic OpenAPI
| Step | Status | Description |
|------|--------|-------------|
| Dark mode fix | ✅ | Added `--bg-code` to CSS variables (dark + light). Replaced all hardcoded light-mode fallbacks in docs CSS with theme-aware variables. Table styling, code blocks, nav items all readable in dark mode. |
| Loading & error handling | ✅ | Replaced borrowed `settings-placeholder` with docs-specific pulse animation. Added error state with retry button for failed doc list fetch. |
| Topbar navigation | ✅ | Imported `Topbar` + `UserMenu` into docs surface. Users can now navigate to other surfaces via the avatar menu. |
| Docs icon + menu entry | ✅ | Added `📖 Docs` entry to UserMenu standard items. Docs accessible from any surface's user menu. |
| `api_schema` manifest field | ✅ | Optional `api_schema` array in `manifest.json`. Parsed lazily by spec builder. Malformed entries logged and skipped — never blocks extension loading. |
| OpenAPI spec builder | ✅ | `BuildOpenAPISpec()` merges static kernel spec with extension routes. Tier 1: auto-generated stubs for all `api_routes`. Tier 2: `api_schema` replaces stubs with rich path items (params, body, response). |
| Dynamic spec endpoint | ✅ | `GET /api/docs/openapi.json` serves merged spec. Swagger UI updated to use JSON endpoint. Static YAML preserved for backward compat. |
| Tests | ✅ | 7 new handler tests: zero extensions, stubs, rich schema, multi-extension, malformed schema, disabled exclusion, required fields. |
| Version | Title |
|---------|-------|
| v0.8.0 | `files` Module |
| v0.8.1 | `workspace` Module |
| v0.8.2 | Capability Negotiation |
| v0.8.3 | Vector Column Type |
| v0.8.4 | Documentation Refresh + Surface Sizing Fix |
| v0.8.5 | Extension Composability |
---
## v0.6.x — Hardening
## 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 — Workflow Redesign
### v0.6.3 — Dead Code Sweep + Registry Fix
Design doc: `docs/DESIGN-workflow-redesign.md`
Pure cleanup. No behavior changes except fixing the broken registry install flow.
The workflow system (~7,600 lines, 25+ files) was built incrementally and
has accumulated redundant concepts, buried primitives, and a read-only
Starlark module. This series cleans up the debt and promotes reusable
primitives before building reference extensions on top.
| Step | Status | Description |
|------|--------|-------------|
| Fix registry install | ✅ | SDK sends `{ url }`, handler expects `{ download_url }`. Fix `api-domains.js` to send `{ download_url: url }`. Every Install click currently returns 400. |
| Registry settings UI | ✅ | Add "Package Registry" section to admin settings with URL input field. Only way to configure registry today is a raw `PUT /api/v1/admin/settings/package_registry` — no user will find it. |
| Registry tooling + docs | ✅ | `scripts/generate-registry.sh` scans a directory of `.pkg` files and emits registry JSON. `docs/PACKAGE-REGISTRY.md` documents the format. |
| Delete dead kernel Go | ✅ | `store/interfaces.go:178183` — orphaned ChannelListFilter comments. `pages/pages.go:922930``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.9.0 — Starlark Converter Consolidation + Snapshot Cleanup**
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
Three files contain near-identical Go↔Starlark converters; three copies
of the snapshot parser exist. Consolidate into `sandbox/convert.go` and
one exported snapshot function. Standardize on wrapped snapshot format.
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.1 — Team User Roles**
| Step | Status | Description |
|------|--------|-------------|
| "Health / Metrics" admin tab | ✅ | New tab in Admin surface. DB-agnostic metrics for all deployments. Cluster cards conditional on PG + multi-node detection. |
| Runtime metrics | ✅ | Per-node: goroutines, heap alloc/sys, stack in use, GC cycles, last GC pause, GC CPU %, uptime, WebSocket clients, extensions loaded, open FDs. |
| DB pool metrics | ✅ | All deployments: DB latency (`SELECT 1` round-trip), pool active/idle/max, wait count, wait duration. PG-only: table bloat (`n_dead_tup`), active backends (`pg_stat_activity`). |
| Cluster metrics | ✅ | PG multi-node only: cluster size, peer list with endpoint + uptime, heartbeat age per node, event bus publish/deliver rates. |
| Extension runtime metrics | ✅ | Starlark exec/min, errors/min, avg duration, HTTP outbound requests/min, trigger fires/min, schedule overruns. |
| Fatten heartbeat payload | ✅ | Heartbeat JSONB carries full metric set. `GET /api/v1/admin/metrics` for single-node SQLite fallback (same shape). |
| Retire `cluster-dashboard` | ✅ | Remove package once Admin Health tab ships. Update `defaultBundledPackages`. |
| Fix block renderer `requires` | ✅ | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` all have `"requires": ["chat"]`. These are content renderers, not chat features. Remove constraint — they should activate without chat. |
| Health endpoint consolidation | ✅ | `/health` and `/api/v1/health` return near-identical JSON. Merge or clearly differentiate with docs. |
Promote the buried role system to a kernel primitive. New
`team_user_roles` table (many-to-many). Manifest `requires_roles` field.
Team admin UI for role assignment. Kernel middleware `RequireRole()`.
Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`.
### v0.6.5 — Renderer Pipeline + Docs Rewrite
**v0.9.2 — Package Adoption + Roles**
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.
`scope: adoptable` manifest field. When a team adopts an adoptable
package, the package's `requires_roles` auto-populate into the team's
role slots. Replaces `AdoptTeamWorkflow` clone mechanism.
| Step | Status | Description |
|------|--------|-------------|
| SDK renderer primitive | ✅ | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. |
| Notes hooks SDK renderer pipeline | ✅ | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. |
| Docs hooks SDK renderer pipeline | ✅ | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
| Unify markdown renderer | ✅ | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
| Sanitize HTML output | ✅ | DOMPurify wired as default post-render step in `sw.markdown`. SVG-safe config allows mermaid output. Notes sanitizes; Docs opts out (system-authored). |
| Mermaid/KaTeX/CSV/Diff work everywhere | ✅ | Browser extension loader injects renderer scripts into all pages. Extensions register via `sw:ready` event. All four render in Notes, Docs, and Chat. |
| Docs rewrite for external audience | ✅ | All fork references removed from docs, CHANGELOG, ROADMAP. DESIGN-WORKFLOW-REDESIGN-0.2.6.md replaced with DESIGN-WORKFLOWS.md. |
| Add Mermaid architecture diagrams | ✅ | Six diagrams in ARCHITECTURE.md: system overview, request flow, extension lifecycle, realtime events, settings cascade, cluster topology. |
| Surface alias decision | ✅ | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. |
| `CONTRIBUTING.md` + tutorial | ✅ | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. |
**v0.9.3 — Typed Forms → SDK Primitive**
### v0.6.7 — Native mTLS
Extract `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()`
and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package
can declare forms, not just workflow stages.
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.4 — Deprecate `stage_type`, Collapse `stage_mode`**
| Step | Status | Description |
|------|--------|-------------|
| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. |
| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)``external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go``mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. |
| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. |
| `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. |
`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook`
presence. Remove from new manifests, keep parsing for backward compat.
Collapse `stage_mode` from 4 to 3 values: form / delegated / automated.
`review` was just `delegated` with signoff — signoff is independent of
rendering mode.
### v0.6.6 — Final Hardening
**v0.9.5 — Full Read/Write Workflow Starlark Module**
Final pass before public release. Security, correctness, and developer experience.
Add `workflow.start()`, `workflow.advance()`, `workflow.cancel()`,
`workflow.submit_signoff()` to the Starlark module. Extract engine
interface to break circular import. Unlocks fully automated workflow
orchestration from Starlark hooks and extensions.
| Step | Status | Description |
|------|--------|-------------|
| Extension dependency auto-activation | ✅ | Installing a package with unmet `depends`/`requires` auto-installs dependencies from the bundled set. If not bundled: clear error listing what's missing. |
| `ValidateManifest()` gate | ✅ | Single `ValidateManifest()` function in `package_validate.go`. Called at install time (both upload and bundled). 12 unit tests. |
| Package signing hook | ✅ | Optional `signature` field in manifest (reserved). `PACKAGE_VERIFY_SIGNATURES` env var (default false, log-only). No cryptographic code yet — schema slot reserved. |
| OIDC state nonce validation | ✅ | `oidcClaims.Nonce` field added. `ValidateIDTokenNonce()` compares ID token nonce against stored state. Callback rejects mismatched nonces. |
| Schema migration stub decision | ✅ | Stub replaced with log-only function documenting additive-only policy. Downgrade rejection preserved. |
| ICD/SDK runner update pass | ✅ | ICD smoke tier: added metrics, cluster, backups, docs, OpenAPI JSON endpoints. SDK admin domain: added metrics, cluster, backups tests. |
| Stale TODO resolution | ✅ | `main.go` session middleware: replaced with `OptionalAuth()` (auth if token present, anonymous pass-through). `auth.go:221` OIDC nonce: resolved. `starlark_helpers.go:96` migration stub: resolved. |
**v0.9.6 — Conditional Routing → SDK Primitive**
Then ship.
Expose `routing.evaluate(rules, data)` as a Starlark SDK function.
Branch rules become a reusable decision engine for any extension.
**v0.9.7 — Multi-Surface Manifest + Route Resolution**
Packages can declare multiple surface routes with independent access
controls. Kernel resolves routes per-page. Enables mixed public/auth
pages in a single package.
**v0.9.8 — Surface Access via Roles**
Wire team roles (v0.9.1) into surface access declarations:
`access: role:approver`. Kernel middleware checks role membership.
Completes the workflow→package access story.
---
## Post-MVP
### v0.10.x — Reference Extensions
- LLM participation (`llm-bridge` extension: subscribes to `chat.message.created`, calls `provider.complete()`, streams response via `realtime.publish`, posts via `chat.send()`. Bot participants with persona config. Multi-model conversations.)
- Rich media extensions: image generation, code sandbox, STT/TTS
- Desktop app (Tauri or Electron)
- Sidecar tier: container-based extensions
- Federation: cross-instance package sharing
- Plugin marketplace with signing and review
The kernel is complete. This series proves the platform by shipping
first-party extensions that exercise every primitive. These are
**extensions, not kernel code** — they ship as `.pkg` files and can be
uninstalled.
**v0.10.0 — `vector-store` Library**
Document ingestion, chunk storage, embedding persistence, semantic
similarity search. Consumes: `db.write`, `files.read`.
**v0.10.1 — `llm-bridge` Library**
Model abstraction, tool-use routing, provider BYOK via connections.
Exposes `complete()`, `embed()`, `classify()`, `register_tool()`.
Consumes: `connections.read`, `api.http`, `db.write`.
**v0.10.2 — `file-share` Extension**
File upload, storage via `files` module, download links, team/group
ACLs via resource grants.
**v0.10.3 — `code-workspace` Extension**
Managed code repos via `workspace` module. Git operations, file browser
surface, structural indexing. Consumes: `workspace.manage`, `files.write`.
**v0.10.4 — `image-gen` + `image-edit` Extensions**
Image generation/editing via external APIs. Composability demo:
contributes to `chat:image-actions` slot.
**v0.10.5 — Chat System**
Generic 1-to-N messaging with slots. `chat-core` library + `chat`
surface. Declares `chat:message-actions`, `chat:image-actions`,
`chat:composer-tools` slots. Consumes: `db.write`, `realtime.publish`,
`files.write`.
---
### v0.11.x — Sidecar Tier + Polish
**v0.11.0 — Sidecar Tier**
Out-of-process extensions for workloads that can't run in Starlark (ML
inference, media transcoding, language servers, local git). Sidecars are
independent processes that connect inward to the kernel, following the
cluster registry pattern. Authentication via mTLS or registration tokens.
**v0.11.1 — Native Dialog Audit**
Replace `prompt()`/`confirm()`/`alert()` with `sw.dialog` SDK primitives.
**v0.11.2 — Stability + Migration Tooling**
Versioned migrations, `armature migrate` CLI, backup/restore validation,
pre-1.0 schema freeze.
---
### 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
- At least 3 reference extensions shipped (vector-store, llm-bridge, chat)
- At least 1 cross-extension composability demo (image-gen → chat:image-actions)
- Headless E2E green on PG + SQLite
- `armature-ca.sh` + mTLS deployment guide
- Single-binary + Docker + K8s deployment paths documented
---
## 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 +252,23 @@ Then ship.
| Decision | Rationale |
|----------|-----------|
| Tasks → extension | Scheduler was the most entangled kernel component (~3,400 lines). Rebuilding as extension validates the trigger system and removes the worst compilation debt. Three trigger primitives (time, webhook, event) replace the monolithic scheduler. |
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). |
| `custom` stage mode | Stage mode `custom` delegates to a surface package, proving extension composability. Chat-in-workflow is handled by the chat extension, not the kernel. |
| Providers removed from kernel | Provider configs, model catalog, routing policies — all moved to extension track. Kernel provides credential storage (connections) and the Starlark `provider.complete` module as the interface. |
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. |
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. |
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. |
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. |
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
| Chat as extension, not kernel | Human-to-human chat built entirely as library + surface packages. Zero kernel awareness of conversations, messages, or participants. Kernel gains one generic `realtime` module. Proves near-infinite extensibility. LLM participation layers on top via a separate bridge extension — the chat system doesn't know or care whether a participant is human or AI. |
| PG as consensus layer | Horizontal scaling uses PG as the sole coordinator (UNLOGGED node_registry + LISTEN/NOTIFY). No etcd, Consul, Redis, or Raft. Rationale: system is already tightly coupled to PG; adding a second consensus layer doubles operational complexity for zero benefit at homelab-to-small-team scale. UNLOGGED table is visible to all connections, survives session disconnect, and cleans up automatically on PG crash — correct behavior since all nodes are dead anyway. Sweep-all for health (every node deletes stale rows) is simpler than ring topology and has no edge cases. |
| Two trigger tiers | Event + webhook triggers are extension-declared (manifest contract, full sandbox). Scheduled tasks are user-created ad-hoc (restricted sandbox — no raw HTTP, no DB table creation, connections-only outbound). Separation keeps extension contracts static and user automation safe. |
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. |
| Builtin package rationale | A builtin must enhance kernel surfaces or be required to demonstrate the platform's own capabilities. **notes** — primary content surface, exercises ext_data/storage/SDK/settings/realtime fully. **chat + chat-core** — primary communication surface, proves the extensibility thesis (100% extension, zero kernel awareness). **mermaid-renderer** — docs surface uses Mermaid diagrams to explain the architecture; without it the platform's own documentation doesn't render (self-bootstrapping). **schedules** — UI for the kernel's scheduled task system; without it users can't manage cron jobs (kernel primitive UI). All others are domain features, examples, dormant, or LLM-only tools — available via registry or `BUNDLED_PACKAGES=*`. |
| Cluster dashboard retired | `cluster-dashboard` shipped as a standalone surface package (v0.6.0) as an expedient. Health/metrics belong inside the Admin surface as a tab — shared context, no separate nav entry, no install required. Merged in v0.6.4. |
| Block renderers decoupled from chat | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` shipped with `"requires": ["chat"]` because renderer discovery lived inside the chat surface. These are content renderers, not chat features. v0.6.4 removes the constraint; v0.6.5 lifts renderer registration to the kernel SDK so all surfaces share it without reimplementing discovery. |
| Tasks → extension | Three trigger primitives replace the monolithic scheduler. |
| Sessions removed | Workflow instances with dedicated storage replace kernel sessions. |
| `custom` stage mode | Delegates to a surface package, proving extension composability. |
| Providers removed from kernel | Connections + Starlark `provider.complete` as the interface. |
| Kernel permissions simplified | 6 platform permissions. Extensions define their own. |
| Preact+htm retained | 3KB runtime, no build step, KISS. |
| Single Docker image | Go binary + assets + migrations. |
| Admin → RBAC group | Grant check replaces role check. |
| Settings cascade | Scope auth + `user_overridable`. Two orthogonal axes. |
| Chat as extension, not kernel | Zero kernel awareness. Proves extensibility thesis. |
| PG as consensus layer | UNLOGGED node_registry + LISTEN/NOTIFY. No etcd/Consul/Redis. |
| Two trigger tiers | Extension-declared (full sandbox) vs user ad-hoc (restricted). |
| Two-slot topbar model | Left slot (title/branding) + center slot (`flex: 1`, tabs/pickers). |
| `db` module is the structured store | No separate KV primitive. Extensions declare tables. |
| `files` module rides ObjectStore | No new kernel tables. Metadata as companion objects. |
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
| Sidecar deferred to v0.11.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. |

View File

@@ -1 +1 @@
0.6.7
0.8.5

View File

@@ -1,11 +1,11 @@
apiVersion: v2
name: switchboard
description: Switchboard Core — self-hosted extension platform
name: armature
description: Armature — self-hosted extension platform
type: application
version: 0.1.0
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
home: https://gobha.ai
sources:
- https://git.gobha.me/switchboard/core
- https://git.gobha.me/armature/core
maintainers:
- name: xcaliber

View File

@@ -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:
# monitoring.prometheusRule.enabled: true
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: switchboard-alerts
name: armature-alerts
spec:
groups:
- name: switchboard.rules
- name: armature.rules
rules:
# Pod restart (possible OOM)
- alert: SwitchboardPodRestart
- alert: ArmaturePodRestart
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
for: 0m
labels:
severity: warning
annotations:
summary: "Switchboard backend pod restarted (possible OOM)"
summary: "Armature backend pod restarted (possible OOM)"
description: "Container {{ $labels.container }} in pod {{ $labels.pod }} restarted."
# Provider down for 5+ minutes
- alert: SwitchboardProviderDown
expr: switchboard_provider_status > 2
- alert: ArmatureProviderDown
expr: armature_provider_status > 2
for: 5m
labels:
severity: critical
@@ -29,8 +29,8 @@ spec:
summary: "Provider {{ $labels.provider_config_id }} is down"
# DB pool >80% utilized
- alert: SwitchboardDBPoolExhaustion
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
- alert: ArmatureDBPoolExhaustion
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
for: 5m
labels:
severity: warning
@@ -38,10 +38,10 @@ spec:
summary: "DB connection pool >80% utilized"
# HTTP 5xx error rate >5%
- alert: SwitchboardHighErrorRate
- alert: ArmatureHighErrorRate
expr: >
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(armature_http_requests_total[5m])) > 0.05
for: 5m
labels:
severity: warning
@@ -49,10 +49,10 @@ spec:
summary: "HTTP 5xx error rate exceeds 5%"
# Task failure rate >25%
- alert: SwitchboardTaskFailureRate
- alert: ArmatureTaskFailureRate
expr: >
rate(switchboard_task_executions_total{status="error"}[15m])
/ rate(switchboard_task_executions_total[15m]) > 0.25
rate(armature_task_executions_total{status="error"}[15m])
/ rate(armature_task_executions_total[15m]) > 0.25
for: 10m
labels:
severity: warning
@@ -60,8 +60,8 @@ spec:
summary: "Task failure rate exceeds 25% over 15 minutes"
# No completions processed in 15 minutes (canary)
- alert: SwitchboardNoCompletions
expr: sum(rate(switchboard_completions_total[10m])) == 0
- alert: ArmatureNoCompletions
expr: sum(rate(armature_completions_total[10m])) == 0
for: 15m
labels:
severity: critical

View File

@@ -17,10 +17,10 @@
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
],
"id": null,
"uid": "switchboard-overview",
"title": "Switchboard Core — Overview",
"uid": "armature-overview",
"title": "Armature — Overview",
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
"tags": ["switchboard"],
"tags": ["armature"],
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 38,
@@ -37,7 +37,7 @@
"name": "namespace",
"type": "query",
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(switchboard_http_requests_total, namespace)",
"query": "label_values(armature_http_requests_total, namespace)",
"includeAll": true,
"current": { "text": "All", "value": "$__all" }
},
@@ -45,7 +45,7 @@
"name": "pod",
"type": "query",
"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,
"current": { "text": "All", "value": "$__all" }
}
@@ -58,7 +58,7 @@
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
"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"
}
]
@@ -69,7 +69,7 @@
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
"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"
}
],
@@ -93,15 +93,15 @@
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
"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"
},
{
"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"
},
{
"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"
}
],
@@ -113,7 +113,7 @@
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
"targets": [
{
"expr": "sum(switchboard_websocket_connections{namespace=~\"$namespace\"})",
"expr": "sum(armature_websocket_connections{namespace=~\"$namespace\"})",
"legendFormat": "Active"
}
]
@@ -124,7 +124,7 @@
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"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}}"
}
]
@@ -135,7 +135,7 @@
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"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}}"
}
],
@@ -147,7 +147,7 @@
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"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}}"
}
]
@@ -158,7 +158,7 @@
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"targets": [
{
"expr": "switchboard_provider_status{namespace=~\"$namespace\"}",
"expr": "armature_provider_status{namespace=~\"$namespace\"}",
"legendFormat": "{{provider_config_id}}"
}
],
@@ -179,15 +179,15 @@
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
"targets": [
{
"expr": "switchboard_db_open_connections{namespace=~\"$namespace\"}",
"expr": "armature_db_open_connections{namespace=~\"$namespace\"}",
"legendFormat": "Open"
},
{
"expr": "switchboard_db_in_use_connections{namespace=~\"$namespace\"}",
"expr": "armature_db_in_use_connections{namespace=~\"$namespace\"}",
"legendFormat": "In Use"
},
{
"expr": "switchboard_db_idle_connections{namespace=~\"$namespace\"}",
"expr": "armature_db_idle_connections{namespace=~\"$namespace\"}",
"legendFormat": "Idle"
}
]
@@ -198,7 +198,7 @@
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
"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}}"
}
]

View File

@@ -1,4 +1,4 @@
Chat Switchboard {{ .Chart.AppVersion }} deployed.
Armature {{ .Chart.AppVersion }} deployed.
{{- if .Values.ingress.enabled }}
Access the application at:

View File

@@ -1,7 +1,7 @@
{{/*
Common labels
*/}}
{{- define "switchboard.labels" -}}
{{- define "armature.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
@@ -12,23 +12,23 @@ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{/*
Backend selector labels
*/}}
{{- define "switchboard.backend.labels" -}}
{{- define "armature.backend.labels" -}}
app.kubernetes.io/component: backend
{{ include "switchboard.labels" . }}
{{ include "armature.labels" . }}
{{- end }}
{{/*
Frontend selector labels
*/}}
{{- define "switchboard.frontend.labels" -}}
{{- define "armature.frontend.labels" -}}
app.kubernetes.io/component: frontend
{{ include "switchboard.labels" . }}
{{ include "armature.labels" . }}
{{- end }}
{{/*
Secret name
*/}}
{{- define "switchboard.secretName" -}}
{{- define "armature.secretName" -}}
{{- if .Values.existingSecret -}}
{{ .Values.existingSecret }}
{{- else -}}
@@ -39,21 +39,21 @@ Secret name
{{/*
Backend image
*/}}
{{- define "switchboard.backend.image" -}}
{{- define "armature.backend.image" -}}
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
{{- end }}
{{/*
Frontend image
*/}}
{{- define "switchboard.frontend.image" -}}
{{- define "armature.frontend.image" -}}
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
{{- end }}
{{/*
Database URL assembled from postgres.* if url is empty
*/}}
{{- define "switchboard.databaseURL" -}}
{{- define "armature.databaseURL" -}}
{{- if .Values.database.url -}}
{{ .Values.database.url }}
{{- else if eq .Values.database.driver "sqlite" -}}

View File

@@ -3,7 +3,7 @@ kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
data:
PORT: {{ .Values.backend.port | quote }}
BASE_PATH: {{ .Values.basePath | quote }}

View File

@@ -4,7 +4,7 @@ kind: CronJob
metadata:
name: {{ .Release.Name }}-backup
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
app.kubernetes.io/component: backup
spec:
schedule: {{ .Values.backup.schedule | quote }}
@@ -35,7 +35,7 @@ spec:
- |
set -e
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
FILENAME="switchboard-${TIMESTAMP}.sql.gz"
FILENAME="armature-${TIMESTAMP}.sql.gz"
echo "Starting backup: ${FILENAME}"
pg_dump "${DATABASE_URL}" | gzip > "/backup/${FILENAME}"
@@ -58,22 +58,22 @@ spec:
# Prune old local backups beyond retention
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"
env:
- name: DATABASE_URL
value: {{ include "switchboard.databaseURL" . | quote }}
value: {{ include "armature.databaseURL" . | quote }}
{{- if .Values.backup.s3.enabled }}
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "switchboard.secretName" . }}
name: {{ include "armature.secretName" . }}
key: BACKUP_S3_ACCESS_KEY
optional: true
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "switchboard.secretName" . }}
name: {{ include "armature.secretName" . }}
key: BACKUP_S3_SECRET_KEY
optional: true
{{- end }}

View File

@@ -3,7 +3,7 @@ kind: Deployment
metadata:
name: {{ .Release.Name }}-backend
labels:
{{- include "switchboard.backend.labels" . | nindent 4 }}
{{- include "armature.backend.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
@@ -38,7 +38,7 @@ spec:
topologyKey: kubernetes.io/hostname
containers:
- name: backend
image: {{ include "switchboard.backend.image" . }}
image: {{ include "armature.backend.image" . }}
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- name: http
@@ -48,10 +48,10 @@ spec:
- configMapRef:
name: {{ .Release.Name }}-config
- secretRef:
name: {{ include "switchboard.secretName" . }}
name: {{ include "armature.secretName" . }}
env:
- name: DATABASE_URL
value: {{ include "switchboard.databaseURL" . | quote }}
value: {{ include "armature.databaseURL" . | quote }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- if .Values.persistence.enabled }}

View File

@@ -3,7 +3,7 @@ kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
labels:
{{- include "switchboard.frontend.labels" . | nindent 4 }}
{{- include "armature.frontend.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.frontend.replicaCount }}
selector:
@@ -24,7 +24,7 @@ spec:
{{- end }}
containers:
- name: frontend
image: {{ include "switchboard.frontend.image" . }}
image: {{ include "armature.frontend.image" . }}
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- name: http

View File

@@ -2,13 +2,13 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "switchboard.fullname" . }}-grafana-dashboard
name: {{ include "armature.fullname" . }}-grafana-dashboard
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
{{- with .Values.monitoring.grafanaDashboard.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
data:
switchboard-overview.json: |-
{{- .Files.Get "dashboards/switchboard-overview.json" | nindent 4 }}
armature-overview.json: |-
{{- .Files.Get "dashboards/armature-overview.json" | nindent 4 }}
{{- end }}

View File

@@ -4,7 +4,7 @@ kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
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) }}
annotations:
{{- with .Values.ingress.annotations }}

View File

@@ -5,7 +5,7 @@ kind: Middleware
metadata:
name: {{ .Release.Name }}-retry
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
spec:
retry:
attempts: {{ .Values.ingress.retry.attempts | default 2 }}

View File

@@ -2,57 +2,57 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: {{ include "switchboard.fullname" . }}-alerts
name: {{ include "armature.fullname" . }}-alerts
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
{{- with .Values.monitoring.prometheusRule.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
groups:
- name: switchboard.rules
- name: armature.rules
rules:
- alert: SwitchboardPodRestart
- alert: ArmaturePodRestart
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
for: 0m
labels:
severity: warning
annotations:
summary: "Switchboard backend pod restarted (possible OOM)"
- alert: SwitchboardProviderDown
expr: switchboard_provider_status > 2
summary: "Armature backend pod restarted (possible OOM)"
- alert: ArmatureProviderDown
expr: armature_provider_status > 2
for: 5m
labels:
severity: critical
annotations:
summary: "Provider {{`{{ $labels.provider_config_id }}`}} is down"
- alert: SwitchboardDBPoolExhaustion
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
- alert: ArmatureDBPoolExhaustion
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "DB connection pool >80% utilized"
- alert: SwitchboardHighErrorRate
- alert: ArmatureHighErrorRate
expr: |
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(armature_http_requests_total[5m])) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "HTTP 5xx error rate exceeds 5%"
- alert: SwitchboardTaskFailureRate
- alert: ArmatureTaskFailureRate
expr: |
rate(switchboard_task_executions_total{status="error"}[15m])
/ rate(switchboard_task_executions_total[15m]) > 0.25
rate(armature_task_executions_total{status="error"}[15m])
/ rate(armature_task_executions_total[15m]) > 0.25
for: 10m
labels:
severity: warning
annotations:
summary: "Task failure rate exceeds 25%"
- alert: SwitchboardNoCompletions
expr: sum(rate(switchboard_completions_total[10m])) == 0
- alert: ArmatureNoCompletions
expr: sum(rate(armature_completions_total[10m])) == 0
for: 15m
labels:
severity: critical

View File

@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-backup
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
app.kubernetes.io/component: backup
spec:
accessModes:

View File

@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-data
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}

View File

@@ -4,7 +4,7 @@ kind: Secret
metadata:
name: {{ .Release.Name }}-secrets
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
type: Opaque
stringData:
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
@@ -15,11 +15,11 @@ stringData:
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
{{- end }}
{{- if .Values.admin.password }}
SWITCHBOARD_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
SWITCHBOARD_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
ARMATURE_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
ARMATURE_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
{{- end }}
{{- if .Values.admin.email }}
SWITCHBOARD_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
ARMATURE_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
{{- end }}
{{- if .Values.storage.s3.accessKey }}
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}

View File

@@ -2,16 +2,16 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "switchboard.fullname" . }}
name: {{ include "armature.fullname" . }}
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- include "armature.labels" . | nindent 4 }}
{{- with .Values.monitoring.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "switchboard.selectorLabels" . | nindent 6 }}
{{- include "armature.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: backend
endpoints:
- port: http

View File

@@ -3,7 +3,7 @@ kind: Service
metadata:
name: {{ .Release.Name }}-backend
labels:
{{- include "switchboard.backend.labels" . | nindent 4 }}
{{- include "armature.backend.labels" . | nindent 4 }}
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: {{ .Values.backend.port | quote }}
@@ -25,7 +25,7 @@ kind: Service
metadata:
name: {{ .Release.Name }}-frontend
labels:
{{- include "switchboard.frontend.labels" . | nindent 4 }}
{{- include "armature.frontend.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:

View File

@@ -1,10 +1,10 @@
# Switchboard Core — Helm values
# helm install switchboard ./chart
# Armature — Helm values
# helm install armature ./chart
# ── Images ─────────────────────────────────
backend:
image:
repository: git.gobha.me/switchboard/core
repository: git.gobha.me/armature/core
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
replicaCount: 2 # v0.32.0: multi-replica HA
@@ -19,7 +19,7 @@ backend:
frontend:
image:
repository: git.gobha.me/switchboard/core
repository: git.gobha.me/armature/core
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
replicaCount: 1
@@ -41,12 +41,12 @@ database:
postgres:
host: postgresql
port: 5432
user: switchboard
user: armature
password: "" # set via secret
database: switchboard
database: armature
sslmode: disable
# For sqlite: path inside the container (requires persistence)
sqlitePath: /data/switchboard.db
sqlitePath: /data/armature.db
# ── Core ───────────────────────────────────
basePath: "" # URL prefix, e.g. "/dev"
@@ -85,7 +85,7 @@ ingress:
enabled: true
className: traefik
annotations: {}
host: switchboard.local
host: armature.local
tls:
enabled: false
secretName: ""

11
ci/Dockerfile.test-runner Normal file
View 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

View File

@@ -103,7 +103,7 @@ done
# ── Test 4: Stop one replica → stale 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..."
sleep "$STALE_WAIT"
@@ -118,7 +118,7 @@ fi
# ── Test 5: Restart replica → re-registers ────
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
for i in $(seq 1 30); do

View File

@@ -3,10 +3,10 @@ events {
}
http {
upstream switchboard {
server switchboard-1:80;
server switchboard-2:80;
server switchboard-3:80;
upstream armature {
server armature-1:80;
server armature-2:80;
server armature-3:80;
}
# WebSocket upgrade map
@@ -19,7 +19,7 @@ http {
listen 80;
location / {
proxy_pass http://switchboard;
proxy_pass http://armature;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -28,7 +28,7 @@ http {
# WebSocket endpoint
location /ws {
proxy_pass http://switchboard;
proxy_pass http://armature;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

225
ci/e2e-smoke-driver.js Normal file
View 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
View 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

View File

@@ -83,11 +83,11 @@ authed() {
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
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"
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"
# ═══════════════════════════════════════════════
@@ -97,7 +97,7 @@ ok "new image built"
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
# 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
# Wait for postgres
@@ -107,30 +107,30 @@ sleep 3
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
-e PORT=8080 -e BASE_PATH="" \
-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 "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=admin \
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
-e LOG_FORMAT=text -e LOG_LEVEL=info \
-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" \
-e PORT=8080 -e BASE_PATH="" \
-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 "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=admin \
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
-e LOG_FORMAT=text -e LOG_LEVEL=info \
-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
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" \
-e PORT=8080 -e BASE_PATH="" \
-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 "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=admin \
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
-e LOG_FORMAT=text -e LOG_LEVEL=info \
-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"
@@ -246,16 +246,16 @@ echo "Starting new replica-2..."
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
-e PORT=8080 -e BASE_PATH="" \
-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 "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=admin \
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
-e LOG_FORMAT=text -e LOG_LEVEL=info \
-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"

View File

@@ -82,13 +82,13 @@ echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
# Build "old" image from last commit (before current changes)
echo "Building 'old' image from HEAD commit..."
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
ok "old image built"
# Build "new" image from working tree (with current changes)
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"
# ═══════════════════════════════════════════════
@@ -97,7 +97,7 @@ ok "new image built"
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"
# Auth
@@ -179,17 +179,17 @@ ok "recorded $PRE_PKG_COUNT installed packages"
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
echo "Stopping old version..."
docker compose -f "$COMPOSE_FILE" stop switchboard-old
docker compose -f "$COMPOSE_FILE" stop armature-old
ok "old version stopped"
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"
ok "new version started"
# Check for migration errors in logs
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
fail "migration errors found in logs"
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5

232
ci/e2e-workflow-handoff.sh Executable file
View 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\": \"review\",
\"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
View 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": "review",
"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

View File

@@ -1,5 +1,5 @@
{
"realm": "switchboard",
"realm": "armature",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
@@ -9,11 +9,11 @@
"realm": [
{
"name": "sb-admin",
"description": "Switchboard admin role"
"description": "Armature admin role"
},
{
"name": "sb-user",
"description": "Switchboard regular user role"
"description": "Armature regular user role"
}
]
},
@@ -29,11 +29,11 @@
],
"clients": [
{
"clientId": "switchboard",
"name": "Switchboard Core",
"clientId": "armature",
"name": "Armature",
"enabled": true,
"publicClient": false,
"secret": "switchboard-secret",
"secret": "armature-secret",
"redirectUris": [
"http://localhost:3000/*",
"http://localhost:8080/*"
@@ -69,7 +69,7 @@
"users": [
{
"username": "alice",
"email": "alice@switchboard.test",
"email": "alice@armature.test",
"firstName": "Alice",
"lastName": "Engineer",
"enabled": true,
@@ -83,7 +83,7 @@
],
"realmRoles": [
"sb-user",
"default-roles-switchboard"
"default-roles-armature"
],
"groups": [
"/engineering"
@@ -91,7 +91,7 @@
},
{
"username": "bob",
"email": "bob@switchboard.test",
"email": "bob@armature.test",
"firstName": "Bob",
"lastName": "Lead",
"enabled": true,
@@ -106,7 +106,7 @@
"realmRoles": [
"sb-admin",
"sb-user",
"default-roles-switchboard"
"default-roles-armature"
],
"groups": [
"/engineering",

56
ci/run-surface-tests.sh Executable file
View 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
View 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
View 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

View File

@@ -1,6 +1,6 @@
# 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
# broadcast via pg_notify and cluster registry (v0.6.0).
#
@@ -14,18 +14,18 @@ services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: switchboard_e2e
POSTGRES_USER: switchboard
POSTGRES_DB: armature_e2e
POSTGRES_USER: armature
POSTGRES_PASSWORD: e2e-password
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_e2e"]
test: ["CMD-SHELL", "pg_isready -U armature -d armature_e2e"]
interval: 2s
timeout: 5s
retries: 10
switchboard-1:
armature-1:
build:
context: .
dockerfile: Dockerfile
@@ -33,11 +33,11 @@ services:
PORT: "8080"
BASE_PATH: ""
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
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: "*"
@@ -48,14 +48,14 @@ services:
CLUSTER_NODE_ID: "node-1"
CLUSTER_HEARTBEAT_INTERVAL: "5s"
CLUSTER_STALE_THRESHOLD: "15s"
CLUSTER_ENDPOINT: "http://switchboard-1:8080"
CLUSTER_ENDPOINT: "http://armature-1:8080"
depends_on:
postgres:
condition: service_healthy
ports:
- "8081:80"
switchboard-2:
armature-2:
build:
context: .
dockerfile: Dockerfile
@@ -63,11 +63,11 @@ services:
PORT: "8080"
BASE_PATH: ""
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
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: "*"
@@ -78,14 +78,14 @@ services:
CLUSTER_NODE_ID: "node-2"
CLUSTER_HEARTBEAT_INTERVAL: "5s"
CLUSTER_STALE_THRESHOLD: "15s"
CLUSTER_ENDPOINT: "http://switchboard-2:8080"
CLUSTER_ENDPOINT: "http://armature-2:8080"
depends_on:
postgres:
condition: service_healthy
ports:
- "8082:80"
switchboard-3:
armature-3:
build:
context: .
dockerfile: Dockerfile
@@ -93,11 +93,11 @@ services:
PORT: "8080"
BASE_PATH: ""
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
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: "*"
@@ -108,7 +108,7 @@ services:
CLUSTER_NODE_ID: "node-3"
CLUSTER_HEARTBEAT_INTERVAL: "5s"
CLUSTER_STALE_THRESHOLD: "15s"
CLUSTER_ENDPOINT: "http://switchboard-3:8080"
CLUSTER_ENDPOINT: "http://armature-3:8080"
depends_on:
postgres:
condition: service_healthy
@@ -122,6 +122,6 @@ services:
ports:
- "3000:80"
depends_on:
- switchboard-1
- switchboard-2
- switchboard-3
- armature-1
- armature-2
- armature-3

View File

@@ -6,11 +6,11 @@
# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up
#
# This extends the base docker-compose.yml to add:
# - Keycloak IdP with a pre-configured "switchboard" realm
# - Switchboard configured with AUTH_MODE=oidc pointing at Keycloak
# - Keycloak IdP with a pre-configured "armature" realm
# - Armature configured with AUTH_MODE=oidc pointing at Keycloak
#
# After startup:
# Switchboard: http://localhost:3000
# Armature: http://localhost:3000
# Keycloak: http://localhost:8180 (admin / admin)
#
# Pre-configured test users in Keycloak:
@@ -21,14 +21,14 @@
# ============================================
services:
# Override switchboard to use OIDC
switchboard:
# Override armature to use OIDC
armature:
environment:
AUTH_MODE: oidc
OIDC_ISSUER_URL: http://keycloak:8080/realms/switchboard
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/switchboard
OIDC_CLIENT_ID: switchboard
OIDC_CLIENT_SECRET: switchboard-secret
OIDC_ISSUER_URL: http://keycloak:8080/realms/armature
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/armature
OIDC_CLIENT_ID: armature
OIDC_CLIENT_SECRET: armature-secret
OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback
OIDC_AUTO_ACTIVATE: "true"
OIDC_ADMIN_ROLE: sb-admin
@@ -38,7 +38,7 @@ services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: switchboard-keycloak
container_name: armature-keycloak
command:
- start-dev
- --import-realm
@@ -48,7 +48,7 @@ services:
KC_HTTP_PORT: "8080"
KC_HEALTH_ENABLED: "true"
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:
- "8180:8080"
healthcheck:

View File

@@ -7,11 +7,11 @@
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
#
# Manual usage:
# docker build -t switchboard-core:v-old .
# docker compose -f docker-compose-upgrade.yml up postgres switchboard-old -d
# docker build -t armature:v-old .
# docker compose -f docker-compose-upgrade.yml up postgres armature-old -d
# # ... seed data ...
# docker compose -f docker-compose-upgrade.yml stop switchboard-old
# docker compose -f docker-compose-upgrade.yml up switchboard-new -d
# docker compose -f docker-compose-upgrade.yml stop armature-old
# docker compose -f docker-compose-upgrade.yml up armature-new -d
# # ... verify data ...
# docker compose -f docker-compose-upgrade.yml down -v
@@ -19,28 +19,28 @@ services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: switchboard_upgrade
POSTGRES_USER: switchboard
POSTGRES_DB: armature_upgrade
POSTGRES_USER: armature
POSTGRES_PASSWORD: upgrade-password
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_upgrade"]
test: ["CMD-SHELL", "pg_isready -U armature -d armature_upgrade"]
interval: 2s
timeout: 5s
retries: 10
switchboard-old:
image: switchboard-core:v-old
armature-old:
image: armature:v-old
environment:
PORT: "8080"
BASE_PATH: ""
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
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: "*"
@@ -56,17 +56,17 @@ services:
volumes:
- upgrade_storage:/data/storage
switchboard-new:
image: core-switchboard-new
armature-new:
image: core-armature-new
environment:
PORT: "8080"
BASE_PATH: ""
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
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: "*"

69
docker-compose.ci.yml Normal file
View 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"]

View File

@@ -13,20 +13,20 @@
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
services:
switchboard:
armature:
build:
context: .
dockerfile: Dockerfile
container_name: switchboard-core
container_name: armature
environment:
PORT: "8080"
BASE_PATH: ""
DB_DRIVER: sqlite
DATABASE_URL: /data/switchboard.db
DATABASE_URL: /data/armature.db
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-for-production}
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
ARMATURE_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ARMATURE_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# ============================================
# Switchboard Core - Backend Launcher
# Armature - Backend Launcher
# ============================================
# Runs as an nginx entrypoint.d hook.
# Starts the Go backend in the background
@@ -20,15 +20,15 @@ else
-exec sed -i "s|%%BASE_PATH%%||g" {} +
fi
echo "🔀 Starting Switchboard Core backend..."
echo "🔀 Starting Armature backend..."
# 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
# Launch Go backend in background (from /app so migrations are found)
cd /app
/usr/local/bin/switchboard &
/usr/local/bin/armature &
BACKEND_PID=$!
# Wait for backend to be ready (max 60s)

View File

@@ -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.
Everything else — chat, AI providers, personas, knowledge bases, notes,
tools — ships as installable extensions.
@@ -271,12 +271,13 @@ graph TD
## Frontend
Preact (3KB) + htm (tagged template literals). No build step, no bundler
(except CM6 via esbuild). IIFE/global-namespace pattern with
`sb.register()`/`sb.ns()`.
(except CM6 via esbuild). ES modules loaded via `<script type="module">`.
The SDK is exposed at `window.sw` — see the [Frontend JS Guide](FRONTEND-JS-GUIDE).
The shell loads surfaces into a viewport. Extensions use `window.html`
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image.
The shell provides a two-slot topbar (left title + center slot) that every
surface inherits. Extensions use `window.html` and `window.preact` directly.
Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
CodeMirror 6) baked into the image.
## Deployment
@@ -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
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`
### Cluster Topology

209
docs/AUDIT-surfaces.md Normal file
View File

@@ -0,0 +1,209 @@
# Surface Audit — Settings, Admin, Team Admin, Docs
## Audit Methodology
For each surface: read the index.js (tab/section structure), read every
section module, check the HTML template, trace the topbar/navigation
pattern, identify bugs, missing features, dead ends, and legacy baggage.
---
## 1. Settings Surface
**Files:** `src/js/sw/surfaces/settings/` (7 files, ~868 lines)
**Template:** `surfaces/settings.html` — mounts into `#settings-mount`
**Topbar:** Custom — back arrow + user icon + "Settings" title. No bell. No user menu.
### Sections
| Section | Lines | Status | Notes |
|---------|-------|--------|-------|
| General | 62 | ✅ OK | Default surface picker. Clean. |
| Appearance | 78 | ✅ OK | Theme toggle (light/dark/system) + UI scale slider. |
| Profile | 180 | ✅ OK | Display name, email, avatar upload, password change. |
| Teams | 47 | ⚠️ Thin | Read-only list of your teams. No actions. No link to team admin. |
| Connections | 222 | ✅ OK | Personal BYOK connection CRUD. Functional. |
| Notifications | 96 | ✅ OK | Toggle notification types on/off. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| S1 | **P1** | **No notification bell.** Custom topbar renders back arrow + icon + "Settings" — no bell, no user menu dropdown. User can't see notifications or navigate to other surfaces without using the back button. |
| S2 | **P2** | **Teams section is a dead-end.** Lists your teams with no actions — can't leave team, can't navigate to team admin, can't see team details. Just names. Should either link to team admin or show useful info. |
| S3 | **P2** | **Back button uses sessionStorage return URL.** `sb_settings_return` stash means: open settings in a new tab → back goes to `/` (correct). But open settings from a deep link → back goes to referrer, which might be unexpected. Shell topbar with consistent home link would fix this. |
| S4 | **P3** | **Extension config sections.** `__CONFIG_SECTIONS__` injection works but has no documentation. Extension authors don't know they can add settings sections. Needs a docs entry. |
### Shell Topbar Migration
Settings renders its own `settings-topbar`. With shell topbar injection:
- **Option A (simple):** `sw.shell.topbar.hide()` and keep custom topbar. Works immediately.
- **Option B (ideal):** Remove custom topbar. Shell topbar provides back + title + bell + user menu. Settings nav stays in the sidebar.
- **Recommendation:** Option B. Settings topbar adds nothing the shell topbar doesn't. The back arrow just navigates to `/`.
---
## 2. Admin Surface
**Files:** `src/js/sw/surfaces/admin/` (13 files, ~2,522 lines)
**Template:** `surfaces/admin.html` — mounts into `#admin-mount`
**Topbar:** Custom — favicon + "Administration" + category tabs (People/Workflows/System/Monitoring) + UserMenu component. No notification bell.
### Sections
| Section | Category | Lines | Status | Notes |
|---------|----------|-------|--------|-------|
| Users | People | 152 | ✅ OK | User list, create, edit status/role. Functional. |
| Teams | People | 178 | ✅ OK | Team list, create, member management. |
| Groups | People | 207 | ✅ OK | Full CRUD — create, delete, permission toggles, member add/remove. Functional but **undocumented** (see issues). |
| Workflows | Workflows | 163 | ⚠️ | CRUD + stage editor. `sw.api.workflows.list()` — needs same error-surfacing treatment as workflow-demo. |
| Settings | System | 242 | ✅ OK | Comprehensive: default surface, registration, banner, message bar, footer, session TTLs, vault, package registry, email test. Actually solid. |
| Storage | System | 76 | ✅ OK | Status cards, orphan cleanup. Clean. |
| Packages | System | 391 | ⚠️ | Core feature. Large. Package list, install, uninstall, registry browse. **User menu doesn't update after install/uninstall** (main bug Jeff reported). |
| Connections | System | 210 | ✅ OK | Global connection CRUD. |
| Broadcast | System | 44 | ✅ OK | Send broadcast. Minimal. |
| Backup | System | 162 | ✅ OK | Create/restore/download/delete. Works. |
| Health | Monitoring | 209 | ✅ OK | Runtime, DB pool, cluster, extension metrics. |
| Audit | Monitoring | 88 | ✅ OK | Audit log viewer with pagination. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| A1 | **P1** | **User menu not reactive to package changes.** `UserMenu` fetches surface list once on mount (`useEffect([authenticated])`). Installing/uninstalling a package doesn't trigger re-fetch. User must refresh the page to see new surfaces in the menu. Same for role changes (adding as team-admin). |
| A2 | **P1** | **No notification bell.** Admin topbar has category tabs + UserMenu but no NotificationBell component. |
| A3 | **P2** | **Groups: no documentation or inline help.** Admin Groups has full CRUD but zero explanation of what groups are, what permissions mean, or how the RBAC model works. "No groups" → user creates one → sees a list of permission slugs like `surface.admin.access` with no description. Every permission should have a human-readable description. |
| A4 | **P2** | **Workflows: silent error potential.** `sw.api.workflows.list()` — if this fails, `catch (e) { sw.toast(e.message, 'error'); }` fires a toast but leaves the list empty. Better than workflow-demo's silent swallow, but the toast disappears and the user is left with an empty list + no context. Should show inline error state. |
| A5 | **P2** | **Packages: no post-install feedback.** After installing a package, the package list refreshes (good) but the user menu doesn't update (bad — A1). User installs Notes, doesn't see it in the menu, thinks it's broken. |
| A6 | **P3** | **Admin topbar favicon is hardcoded.** Line 142: `<img src="${BASE}/favicon.svg">`. Should respect light/dark theme favicon swap. |
| A7 | **P3** | **Category icon rendering is fragile.** Custom compact SVG format (`C12 12 3\|M19.4 15...`) in `CatIcon`. Works but is unmaintainable — any icon change requires understanding the custom format. Should use standard SVG paths or lucide/feather icons. |
### Shell Topbar Migration
Admin has the most complex custom topbar — category tabs are genuinely useful navigation. Options:
- **Option A (recommended):** `sw.shell.topbar.hide()`. Admin keeps its custom topbar but adds NotificationBell component to its existing right-side area next to UserMenu.
- **Option B:** Shell topbar with `sw.shell.topbar.setSlot()` for category tabs. Works but requires rethinking the layout since shell topbar has fixed structure (home | title | slot | bell | user).
- **Recommendation:** Option A for v0.7.0. Admin's custom topbar is bespoke enough to warrant keeping. Just wire in the bell.
---
## 3. Team Admin Surface
**Files:** `src/js/sw/surfaces/team-admin/` (7 files, ~1,119 lines)
**Template:** `surfaces/team-admin.html` — mounts into `#team-admin-mount`
**Topbar:** Custom — back arrow + "Team Admin: {team name}" title. No bell. No user menu.
### Sections
| Section | Lines | Status | Notes |
|---------|-------|--------|-------|
| Members | ~90 | ✅ OK | Member list, add, remove. Functional. |
| Groups | 37 | ❌ Dead-end | Read-only "No groups" display. No create, no docs, no link to admin. |
| Connections | ~120 | ✅ OK | Team-scoped connections. Same pattern as user/admin connections. |
| Workflows | 723 | ⚠️ Massive | Three tabs: Workflows (CRUD + inline stage editor), Assignments (claim/release/complete), Monitor (active instances + signoff). This is 65% of the surface's code. |
| Settings | 72 | ✅ OK | Team name + description. Clean. |
| Activity | ~80 | ✅ OK | Audit log. Works. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| T1 | **P1** | **Groups is a dead-end.** 37 lines. Read-only list of team groups. No "Create Group" button. No explanation of what groups are. No link to Admin > Groups where creation actually happens. A team admin user who isn't a platform admin literally cannot create team groups. The Admin groups page supports `scope: team` but that creates a global group with team scope — it's unclear if team-admin should even see groups at all. |
| T2 | **P1** | **Workflows "Adopt Global" — same silent-error class.** `sw.api.teams.availableWorkflows(teamId)` — if this fails, the catch fires a toast but the adopt panel shows "No global workflows available" — indistinguishable from "there genuinely aren't any" vs "the API errored." |
| T3 | **P1** | **Workflows is disproportionately complex.** 723 lines — inline stage editor with mode/type selectors, SLA fields, stage reordering, team role assignment per stage. This is a full workflow designer embedded in a tab. It works but it's a maintenance burden and the UX is dense. Question: should this complexity live here or be a separate workflow-designer surface? |
| T4 | **P1** | **No notification bell.** Same as Settings — custom topbar with no bell. |
| T5 | **P2** | **No user menu.** Unlike Admin (which renders UserMenu), Team Admin has no user menu in its topbar. User can't navigate to other surfaces except via the back button. |
| T6 | **P2** | **Signoff panel shows raw user_id.** Line 714: `<span>${s.user_id}</span>` — shows UUID instead of display name. Should use `sw.users.displayName(s.user_id)`. |
| T7 | **P3** | **Back button uses sessionStorage.** Same pattern as Settings (`sb_team_admin_return`). Shell topbar would fix. |
### Shell Topbar Migration
Team Admin has a simple topbar (back + title). Direct replacement:
- Shell topbar provides: home link + "Team Admin: {name}" title + bell + user menu.
- Team name from `sw.api.teams.get(teamId)``sw.shell.topbar.setTitle('Team Admin: ' + team.name)`.
- Delete the custom topbar entirely.
---
## 4. Docs Surface
**Files:** `src/js/sw/surfaces/docs/` (1 file, 313 lines)
**Template:** `surfaces/docs.html` — mounts into `#docs-mount`
**Topbar:** Imports and renders `shell/topbar.js` (the SDK Topbar component). **Only surface that uses the shell Topbar.**
### Features
| Feature | Status | Notes |
|---------|--------|-------|
| Document list sidebar | ✅ OK | Fetches from `/api/v1/docs`, renders nav links. |
| Markdown rendering | ✅ OK | Uses `sw.markdown.renderSync()` + post-renderers (mermaid, katex). |
| Document outline | ✅ OK | Parses H1-H4 from markdown, renders table of contents. |
| Search | ✅ OK | Filters documents in sidebar. |
| URL updates | ✅ OK | `history.replaceState` on doc change. |
| Topbar | ✅ OK | Uses shell `Topbar` component — has title, bell, user menu. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| D1 | **P2** | **Stale content.** The docs themselves may be outdated — GETTING-STARTED, EXTENSION-GUIDE, API-REFERENCE, DEPLOYMENT, PACKAGE-FORMAT were written in v0.6.1. 18 versions later, some content is likely stale. Needs a content review pass. |
| D2 | **P3** | **No docs for RBAC/Groups.** Admin Groups exists with full CRUD but there's no documentation explaining the permission model, what each permission slug means, how groups interact with teams, or how the settings cascade works. This directly causes the "groups WTF" reaction. |
| D3 | **P3** | **No docs for Workflows.** The workflow engine is complex (multi-stage, team roles, signoff gates, SLA, public entry) but has no user-facing documentation. `DESIGN-WORKFLOWS.md` exists but is a design doc, not a user guide. |
| D4 | **P3** | **Shell topbar migration.** Docs already imports `shell/topbar.js` — when shell topbar injection lands, Docs will get a double topbar. Needs migration: delete the import, let shell topbar handle it. Docs currently passes no custom slot content, so it's a pure delete. |
---
## Cross-Surface Issues
These affect multiple or all surfaces:
| # | Severity | Issue | Surfaces |
|---|----------|-------|----------|
| X1 | **P0** | **User menu not reactive.** Package install/uninstall, role changes, team membership changes — none trigger a menu refresh. User must reload the page. | All (via UserMenu component) |
| X2 | **P1** | **No notification bell on 3/4 surfaces.** Only Docs has a bell (via Topbar import). Settings, Admin, and Team Admin all lack it. | Settings, Admin, Team Admin |
| X3 | **P1** | **No user menu on 2/4 surfaces.** Settings and Team Admin have no user menu at all. Admin and Docs have one. | Settings, Team Admin |
| X4 | **P2** | **Every surface has its own topbar.** Four different topbar implementations. None use the (not-yet-existing) shell topbar injection. Shell topbar (v0.7.0) eliminates this duplication. | All |
| X5 | **P2** | **Silent error swallowing.** Multiple sections use `catch (e) { toast }` which fires a toast and leaves an empty/stale UI. Toast disappears after seconds; user is left confused. Every list endpoint needs an inline error state with retry. | Admin Workflows, Team Admin Workflows, Packages |
| X6 | **P2** | **Empty states provide no guidance.** "No groups", "No workflows", "No notifications" — no explanation of what the feature is, why it's empty, or what action to take. Every empty state should have a one-line explanation and a primary action (create, link to docs, etc.). | Admin Groups, Team Admin Groups, Workflows |
| X7 | **P3** | **Raw IDs in UI.** Team Admin signoff panel shows `user_id` UUIDs. Any surface showing IDs should resolve via `sw.users.displayName()`. | Team Admin Workflows |
---
## Recommendations
### Immediate (fold into v0.7.0)
1. **User menu reactivity** — emit `package.changed` and `auth.changed` events over WS + local custom events. UserMenu listens and re-fetches surface list. This is the single most impactful fix.
2. **Shell topbar migration for Settings + Team Admin** — both have simple topbars that the shell topbar directly replaces. Docs deletes its Topbar import. Admin keeps its custom topbar but adds NotificationBell.
3. **Remove Team Admin Groups tab** — it's 37 lines of dead-end. Team-scoped group management should either (a) be added properly with create/edit/delete or (b) removed until it's properly designed. Showing "No groups" with no path forward is worse than not showing the tab.
4. **Error states** — replace `catch { toast }` with inline error + retry UI on every list endpoint. Systematic pass across all four surfaces.
5. **Empty state copy** — every "No X" message gets a one-line explanation + primary action button or doc link.
### Deferred (v0.7.1+ / runner coverage)
6. **Admin Groups documentation** — write a "Permissions & Groups" doc for the docs surface. Explain the RBAC model, list all permission slugs with descriptions, explain group scoping.
7. **Workflow user guide** — write a "Workflows" doc. Entry modes, stage types, team roles, signoff gates, SLA.
8. **Team Admin Workflows simplification** — the 723-line inline stage editor is the most complex piece of UI in the entire application. Consider extracting to a dedicated workflow-designer surface or at minimum breaking into separate files.
9. **Docs content refresh** — review all 5 docs for accuracy at v0.6.18+.
10. **Settings Teams section** — either add useful actions (link to team admin, show team role, leave team) or remove the tab.
---
## Asset Inventory
| Surface | Lines (total) | Sections | Custom Topbar | Bell | UserMenu | Error Handling |
|---------|--------------|----------|---------------|------|----------|---------------|
| Settings | 868 | 6 | Yes (back+icon) | ❌ | ❌ | Toast only |
| Admin | 2,522 | 12 | Yes (tabs+menu) | ❌ | ✅ | Toast only |
| Team Admin | 1,119 | 6 | Yes (back+title) | ❌ | ❌ | Toast only |
| Docs | 313 | 1 | Shell Topbar ✅ | ✅ | ✅ | Inline error ✅ |
Docs is the gold standard. The other three need to converge toward its pattern.

View File

@@ -280,7 +280,7 @@ def on_fire(ctx):
resp = http.post(url, body=payload, headers={
"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

View File

@@ -3,14 +3,14 @@
## Docker Single-Instance
```bash
docker pull ghcr.io/switchboard-core/switchboard-core:latest
docker pull gobha/armature:latest
docker run -p 8080:80 \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-v switchboard-data:/data \
ghcr.io/switchboard-core/switchboard-core:latest
-v armature-data:/data \
gobha/armature:latest
```
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
@@ -22,32 +22,32 @@ services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: switchboard
POSTGRES_USER: switchboard
POSTGRES_DB: armature
POSTGRES_USER: armature
POSTGRES_PASSWORD: secretpassword
volumes:
- pg_data:/var/lib/postgresql/data
switchboard:
image: ghcr.io/switchboard-core/switchboard-core:latest
armature:
image: gobha/armature:latest
ports:
- "8080:80"
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"
ENCRYPTION_KEY: "change-me-in-production"
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: changeme
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: changeme
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
volumes:
- sb_storage:/data/storage
- armature_storage:/data/storage
depends_on:
- postgres
volumes:
pg_data:
sb_storage:
armature_storage:
```
## Kubernetes
@@ -58,7 +58,7 @@ See the `k8s/` directory for example manifests. Key considerations:
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
- Mount a PVC at `/data/storage` or configure S3.
- 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
@@ -70,17 +70,18 @@ See the `k8s/` directory for example manifests. Key considerations:
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
| `BASE_PATH` | | URL prefix (e.g., `/switchboard`) |
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
| `LOG_FORMAT` | `text` | `text` or `json` |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
| `SEED_USERS` | | Dev seed users (ignored in production) |
### S3 Storage Variables
@@ -107,14 +108,14 @@ See the `k8s/` directory for example manifests. Key considerations:
**PostgreSQL** (recommended for production):
```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):
```bash
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.

466
docs/DESIGN-batch-exec.md Normal file
View 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.

View File

@@ -9,7 +9,7 @@
## 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
@@ -241,7 +241,7 @@ Pre-MVP: fold `CREATE UNLOGGED TABLE` into existing schema initialization. No ne
### 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

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

View File

@@ -172,17 +172,17 @@ their own `node-N.key`.
### 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:
```
switchboard-ca init
armature-ca init
→ 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/
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/
```
@@ -280,7 +280,7 @@ The new code is:
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
| `server/auth/mtls_native_test.go` | Unit + integration tests |
| `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
clarity. No behavioral changes to the proxy provider.

View 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 (docs/AUDIT-surfaces.md) 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 37 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.

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

View File

@@ -0,0 +1,407 @@
# DESIGN: Surface Runners — v0.7.1v0.7.3
## Status: v0.7.1 Shipped (framework + migrations), v0.7.2v0.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'`.

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

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

View File

@@ -3,11 +3,11 @@
## Quick Start
```bash
docker pull ghcr.io/switchboard-core/switchboard-core:latest
docker pull gobha/armature:latest
docker run -p 8080:80 \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
ghcr.io/switchboard-core/switchboard-core:latest
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \
gobha/armature:latest
```
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
# Install ALL packages (everything in the image)
docker run -p 8080:80 \
-e BUNDLED_PACKAGES="*" \
ghcr.io/switchboard-core/switchboard-core:latest
gobha/armature:latest
# Install specific packages only
docker run -p 8080:80 \
-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.
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
```bash
docker run -p 8080:80 \
-e SKIP_BUNDLED_PACKAGES=true \
ghcr.io/switchboard-core/switchboard-core:latest
gobha/armature:latest
```
### Custom Bundle Directory
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
docker run -p 8080:80 \
-e BUNDLED_PACKAGES_DIR=/custom/packages \
-v /host/packages:/custom/packages \
ghcr.io/switchboard-core/switchboard-core:latest
gobha/armature:latest
```
## Builder Image
@@ -102,7 +102,7 @@ docker run -p 8080:80 \
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
```bash
docker pull ghcr.io/switchboard-core/builder:latest
docker pull gobha/armature-builder:latest
```
### 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:
```dockerfile
FROM ghcr.io/switchboard-core/builder:latest AS builder
FROM gobha/armature-builder:latest AS builder
WORKDIR /app
COPY server/ .
RUN go build -ldflags="-s -w" -o /bin/switchboard .
RUN go build -ldflags="-s -w" -o /bin/armature .
```
### Building Locally
```bash
docker build -f Dockerfile.builder -t switchboard-builder .
docker build -f Dockerfile.builder -t armature-builder .
```
## 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`
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.
@@ -148,14 +148,14 @@ To exclude specific packages from the bundle, either:
### Forking for Custom Builds
```bash
git clone https://github.com/switchboard-core/switchboard-core.git
cd switchboard-core
git clone https://github.com/gobha/armature.git
cd armature
# Add/modify packages
cp -r my-extension packages/my-extension/
# Build with builder image for faster compilation
docker build -t my-switchboard .
docker build -t my-armature .
```
## Production Deployment
@@ -172,7 +172,7 @@ docker build -t my-switchboard .
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3` |
| `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 |
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
| `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
# PostgreSQL (recommended)
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 ENCRYPTION_KEY="$(openssl rand -hex 32)" \
ghcr.io/switchboard-core/switchboard-core:latest
gobha/armature:latest
# SQLite (evaluation only)
docker run -p 8080:80 \
-e DB_DRIVER=sqlite \
-v switchboard-data:/data \
ghcr.io/switchboard-core/switchboard-core:latest
-v armature-data:/data \
gobha/armature:latest
```
### Storage
@@ -204,12 +204,12 @@ Object storage is required for file uploads and package asset extraction.
```bash
# 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)
docker run \
-e STORAGE_BACKEND=s3 \
-e S3_BUCKET=switchboard \
-e S3_BUCKET=armature \
-e S3_ENDPOINT=https://minio.corp:9000 \
-e S3_ACCESS_KEY=... \
-e S3_SECRET_KEY=... \

196
docs/EXTENSION-CSS.md Normal file
View 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}`.

View File

@@ -72,18 +72,34 @@ Every package has a `manifest.json` at its root. Example for a surface:
| `icon` | no | Emoji icon for sidebar/menu |
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
| `auth` | no | `authenticated` (default) or `public` |
| `permissions` | no | Capabilities requested: `db.write`, `http`, `notifications`, `secrets`, `realtime.publish` |
| `permissions` | no | Sandbox capabilities: `db.write`, `db.read`, `api.http`, `notifications`, `secrets`, `realtime.publish`, `connections.read`, `workflow.access`, `batch.exec`, `files.read`, `files.write`, `workspace.manage` |
| `api_routes` | no | Array of `{method, path}` for extension HTTP endpoints |
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
| `db_tables` | no | Table definitions (see below) |
| `settings` | no | User-configurable settings schema |
| `exports` | libraries | Functions exported for other packages |
| `exports` | no | Functions exported for cross-package calls via `lib.require()` |
| `depends` | no | Array of package IDs this package depends on |
| `slots` | no | Named UI injection points for host surfaces |
| `contributes` | no | Slot contributions into other surfaces |
| `hooks` | no | Event bus subscriptions |
| `config_section` | no | Settings/Admin panel injection (see below) |
| `capabilities` | no | Environment requirements (see below) |
| `user_permissions` | no | Permissions this extension registers for users (see below) |
| `gate_permission` | no | Permission checked before `on_request` executes |
| `schema_version` | no | Integer for additive schema migrations |
## db_tables Schema
Tables are automatically namespaced as `ext_{package_id}_{table_name}`. Column types: `text`, `int`. Every table gets an auto-generated `id` primary key and `created_at` timestamp.
Tables are automatically namespaced as `ext_{package_id}_{table_name}`.
Every table gets an auto-generated `id` primary key and `created_at` timestamp.
Column types: `text`, `int`, `vector(N)`.
The `vector(N)` type stores N-dimensional float vectors for similarity
search via `db.query_similar()`. Storage adapts to the backend:
Postgres + pgvector uses native `vector(N)` with HNSW indexes,
Postgres without pgvector uses `JSONB`, SQLite uses `TEXT`. N can be
14096. See the [Starlark Reference](STARLARK-REFERENCE) for query API.
```json
"db_tables": {
@@ -136,26 +152,231 @@ Extensions can optionally declare an `api_schema` array in their manifest to pro
Only `path` and `method` are required. All other fields are optional. Malformed entries are logged and skipped without blocking extension loading.
## Capabilities
Extensions can declare environment requirements via the `capabilities`
manifest field. The kernel validates these at install time.
```json
{
"capabilities": {
"required": ["postgres"],
"optional": ["pgvector", "workspace"]
}
}
```
- **required** — install is rejected (HTTP 422) if any capability is missing.
- **optional** — install succeeds with a logged warning. Query at runtime
with `settings.has_capability("pgvector")` to adapt behavior.
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
`postgres`. The admin can view detected capabilities at
**Admin > System > Capabilities** or via `GET /admin/capabilities`.
## User Permissions
Extensions can register custom permissions that the admin assigns to
user groups. This controls access to extension features beyond the
sandbox permission model.
```json
{
"user_permissions": ["image-gen.use", "image-gen.admin"],
"gate_permission": "image-gen.use"
}
```
- **user_permissions** — on install, these are merged into the kernel's
permission registry. On uninstall, they are removed. The admin assigns
them to groups in **Admin > Groups**.
- **gate_permission** — if set, the kernel checks this permission before
calling `on_request`. Unauthorized users get a 403 without the
extension code executing.
In Starlark, check permissions inline via `req["permissions"]` or call
`permissions.check(user_id, "image-gen.use")`.
## Extension Composability
Extensions compose with each other through three mechanisms: manifest-declared
**slots** (UI injection points), **contributions** (UI components injected into
those slots), and cross-package **function calls** via `lib.require()`.
### Slots — Host Surfaces Declare Injection Points
A surface declares named slots in its manifest where other extensions can
inject UI components:
```json
{
"id": "notes",
"type": "surface",
"slots": {
"toolbar-actions": {
"description": "Toolbar action buttons",
"context": {
"noteId": "string",
"getContent": "function — returns note body",
"setContent": "function — replaces note body"
}
}
}
}
```
The surface renders slot contents using the SDK helper:
```javascript
html`<div class="toolbar">
${sw.slots.renderAll('notes:toolbar-actions', {
noteId: note.id,
getContent: () => editor.getValue(),
setContent: (text) => editor.setValue(text),
})}
</div>`
```
### Contributions — Extensions Inject UI
An extension declares which slots it contributes to:
```json
{
"id": "note-dictate",
"type": "extension",
"contributes": {
"notes:toolbar-actions": {
"label": "Dictate",
"icon": "🎤",
"description": "Voice-to-text dictation"
}
}
}
```
In its JavaScript, it registers the component:
```javascript
sw.slots.register('notes:toolbar-actions', {
id: 'note-dictate',
priority: 200,
component: ({ noteId, setContent, getContent }) => {
// ... component implementation
return html`<button class="sw-btn sw-btn--ghost">🎤</button>`;
}
});
```
Contributions are soft-coupled — install order doesn't matter. The admin
can view all slots and contributors at **Admin > Packages** or via
`GET /api/v1/admin/slots`.
### Cross-Package Function Calls
Any package that declares `exports` in its manifest can be called by other
packages via `lib.require()`. The caller declares the dependency:
```json
{
"id": "note-ai",
"depends": ["llm-bridge"],
"permissions": ["api.http"]
}
```
In Starlark:
```python
llm = lib.require("llm-bridge")
result = llm.complete([{"role": "user", "content": prompt}])
```
The called function runs with the *target* package's permissions, not the
caller's. This is the same security model as library packages.
### Slot Naming Convention
Slot names follow `{host-package-id}:{slot-name}`. The colon separates the
namespace from the slot. Standard slots for first-party packages:
| Slot | Host | Use Case |
|------|------|----------|
| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting |
| `notes:note-footer` | notes | Related items, AI summary |
| `chat:composer-tools` | chat | Image gen, file attach |
| `chat:message-actions` | chat | Reactions, translate, bookmark |
| `chat:image-actions` | chat | Regen, edit, upscale |
## Starlark Sandbox API
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin):
Starlark scripts run server-side with a 1M operation budget and no
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
the complete module catalog, function signatures, and permission gates.
| Module | Permission | API |
|--------|-----------|-----|
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
## config_section — Settings Panel Injection
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages.
Packages can inject configuration panels into the Settings, Admin, or
Team Admin surfaces. Declare `config_section` in `manifest.json`:
```json
{
"config_section": {
"label": "My Config",
"icon": "M12 2L2 7l10 5 10-5-10-5z",
"component": "js/config.js",
"surfaces": ["settings", "admin"],
"category": "system"
}
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `label` | yes | Navigation label shown in the sidebar or tab list |
| `icon` | no | SVG path data for the nav icon |
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
**How it works:**
1. At page load, the backend scans all enabled packages for `config_section`
entries targeting the current surface.
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
3. The frontend dynamically imports the component module and renders it
as an additional tab/section.
4. The component receives `{ packageId, teamId }` as props.
**Example component** (`js/config.js`):
```javascript
const { html } = window;
const { useState, useEffect } = hooks;
export default function MyConfig({ packageId }) {
const [val, setVal] = useState('');
useEffect(() => {
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
}, []);
return html`<div>
<label>API Key</label>
<input value=${val} onInput=${e => setVal(e.target.value)} />
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
</div>`;
}
```
## Permissions Model
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions.
Extensions declare required permissions in `manifest.json`. The admin
must grant each permission before the extension can use the corresponding
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`.
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
model, user permission slugs, and settings cascade.
## File Structure

256
docs/FRONTEND-JS-GUIDE.md Normal file
View File

@@ -0,0 +1,256 @@
# 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.
## 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.

View File

@@ -12,7 +12,7 @@ docker compose up --build
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
Data persists in the `sb_data` named volume. To reset everything:
Data persists in the `armature_data` named volume. To reset everything:
```bash
docker compose down -v
@@ -20,10 +20,10 @@ docker compose down -v
## First Boot
On first start, Switchboard Core will:
On first start, Armature will:
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.).
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` |
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
@@ -77,7 +77,7 @@ You can also upload `.pkg` archives through the Admin > Packages page.
## From Source
```bash
git clone <repo-url> && cd switchboard-core
git clone <repo-url> && cd armature
cp server/.env.example server/.env # edit DB credentials
cd server && go run .
# Backend on http://localhost:8080

View File

@@ -1,6 +1,6 @@
# 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
@@ -68,9 +68,66 @@ Only `manifest.json` is required. All other directories are optional and include
| `db_tables` | Table definitions with columns and indexes |
| `settings` | User-configurable settings with type, label, description, default |
| `hooks` | Event bus subscription patterns |
| `exports` | Functions exported by library packages |
| `exports` | Functions exported for cross-package calls via `lib.require()` |
| `depends` | Array of package IDs this package depends on |
| `slots` | Named UI injection points (host surfaces declare these) |
| `contributes` | Slot contributions this package injects into other surfaces |
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
| `schema_version` | Integer for additive schema migrations |
## 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
1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes.
@@ -125,6 +182,6 @@ To bundle custom packages into a Docker image:
1. Place your package in `packages/your-package/` with a `manifest.json`.
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.

View File

@@ -15,7 +15,7 @@ The registry is a static JSON file matching the `RegistryResponse` struct:
"title": "Notes",
"version": "0.8.0",
"description": "Markdown notes with backlinks and graph view",
"author": "switchboard",
"author": "armature",
"type": "extension",
"tier": "core",
"download_url": "https://cdn.example.com/pkg/notes.pkg",

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

431
docs/STARLARK-REFERENCE.md Normal file
View File

@@ -0,0 +1,431 @@
# 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.
## 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, 14096). 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): 130 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}}
```

View File

@@ -1,9 +1,9 @@
# Build Your First Browser Extension
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
@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
},
render(lang, code, container) {
container.innerHTML =
'<div style="padding:12px;background:var(--bg-2);' +
'<div style="padding:12px;background:var(--bg-secondary);' +
'border:1px solid var(--border);border-radius:8px">' +
'<strong>Demo:</strong> ' + code +
'</div>';
@@ -74,7 +74,7 @@ The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
takes a name and an options object: `type: 'block'` targets fenced code blocks,
`match` checks the language tag, and `render` receives the language, raw code,
and a container element. The `sw:ready` event fires once the SDK initializes;
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
if already loaded, register immediately. Use CSS variables like `var(--bg-secondary)`
and `var(--border)` to follow the active theme.
## Step 4: Package It

227
docs/USABILITY-SURVEY.md Normal file
View File

@@ -0,0 +1,227 @@
# Armature Usability Survey — Automated Checklist
> **Purpose**: Machine-auditable quality gate for the Armature UI.
> Run all scripts first, then walk each section. A FAIL in any section blocks release.
---
## Prerequisites
Run these scripts from the project root and save their output:
```bash
bash scripts/generate-ui-inventory.sh > ui-inventory.json
bash scripts/check-contrast.sh > contrast-report.txt
bash scripts/generate-coverage-matrix.sh > coverage-matrix.md
bash scripts/audit-touch-targets.sh > touch-targets-report.txt
```
---
## Section 1: Viewport Correctness
**Pass criteria:**
- No CSS file uses `100vh` (should be `100%` or `100dvh`)
- `.sw-shell` uses `height: 100%`, not `100vh`
- All extension surfaces use `height: 100%`
- No `transform: scale()` for zoom (should use CSS `zoom`)
**Files to inspect:**
- `src/css/sw-shell.css`
- `src/css/layout.css`
- `packages/*/css/main.css`
**How to check:**
```bash
grep -rn '100vh' src/css/ packages/*/css/ --include='*.css'
grep -rn 'transform.*scale' src/css/ packages/*/css/ --include='*.css'
```
**Result:** PASS if zero matches. FAIL if any `100vh` or `transform: scale()` for layout sizing.
---
## Section 2: Banner Integration
**Pass criteria:**
- Banners are in-flow (no `position: fixed` on banner elements)
- `--banner-top-height` and `--banner-bottom-height` are defined in `:root`
- Shell layout accounts for banner height via CSS variables, not hardcoded px
- No surface hardcodes `28px` or other banner height values
**Files to inspect:**
- `src/css/sw-shell.css`
- `src/css/variables.css` (`:root` block)
- `src/js/sw/shell/app-shell.js`
**How to check:**
```bash
grep -n 'position.*fixed' src/css/sw-shell.css | grep -i banner
grep -n '28px' src/css/ -r --include='*.css'
grep -n 'banner-top-height\|banner-bottom-height' src/css/variables.css
```
**Result:** PASS if banners are in-flow and height is variable-driven. FAIL if fixed positioning or hardcoded heights.
---
## Section 3: Responsive Behavior
**Pass criteria:**
- Kernel CSS uses `768px` (mobile) and `1024px` (tablet) breakpoints
- No hardcoded widths that break below 768px (except intentional min-widths on dialogs)
- Sidebar collapses on mobile
- Extension surfaces adapt to narrow viewports
**Files to inspect:**
- `src/css/layout.css`
- `src/css/surfaces.css`
- `packages/*/css/main.css`
**How to check:**
```bash
# Verify breakpoints used
grep -rn '@media.*max-width' src/css/ --include='*.css' | grep -v '768\|1024'
# Check for hardcoded widths
grep -rn 'width:.*[0-9]\+px' src/css/layout.css | grep -v 'max-width\|min-width\|--'
```
**Result:** PASS if only 768px and 1024px breakpoints. WARN if other breakpoints exist but are justified. FAIL if layout breaks below 768px.
---
## Section 4: Styling Consistency
**Pass criteria:**
- All spacing uses `--sp-*` tokens (no raw px for padding/margin/gap > 3px)
- All `border-radius` uses `--radius-sm`, `--radius`, or `--radius-lg`
- All `font-family` uses `var(--font)` or `var(--mono)`
- No external font CDN imports (`@import url(` or Google Fonts references)
- No stale fallback colors (`#b38a4e` or other non-token hex in property values)
**Files to inspect:**
- All `src/css/*.css`
- `packages/*/css/main.css`
**How to check:**
```bash
# Raw px spacing (padding/margin/gap > 3px, not inside var())
grep -rnE '(padding|margin|gap):\s*[0-9]+(px|rem)' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--' | grep -v '0px\|1px\|2px\|3px'
# Raw border-radius
grep -rn 'border-radius:' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--radius'
# External fonts
grep -rn '@import url\|fonts.googleapis' src/css/ --include='*.css'
# Stale fallback gold color
grep -rn '#b38a4e' src/css/ packages/*/css/ --include='*.css'
```
**Result:** PASS if zero non-token values (excluding reset/keyframe contexts). WARN for 1-3 edge cases with justification. FAIL for systematic violations.
---
## Section 5: Accessibility — Contrast
**Pass criteria:**
- `contrast-report.txt` shows all PASS for normal text (4.5:1 ratio)
- No FAIL results in either dark or light theme
**Files to inspect:**
- `contrast-report.txt` (generated above)
**How to check:**
```bash
grep 'FAIL' contrast-report.txt
```
**Result:** PASS if zero FAIL lines. FAIL if any contrast violation.
---
## Section 6: Accessibility — Touch Targets
**Pass criteria:**
- `touch-targets-report.txt` shows zero violations
- All close buttons have `min-width: 44px; min-height: 44px` in `@media (max-width: 768px)`
- Menu items have `min-height: 44px` on mobile (already done in `sw-primitives.css`)
**Files to inspect:**
- `touch-targets-report.txt` (generated above)
- `src/css/sw-primitives.css` — close button rules
**How to check:**
```bash
grep 'FAIL\|MISSING' touch-targets-report.txt
```
**Result:** PASS if zero violations. FAIL if any close button lacks mobile touch target.
---
## Section 7: Accessibility — Focus Indicators
**Pass criteria:**
- All interactive primitives have `:focus-visible` styles
- No `outline: none` without a replacement focus indicator
- Focus ring is visible on both dark and light themes
**Files to inspect:**
- `src/css/sw-primitives.css`
- `src/css/primitives.css`
- `src/css/variables.css`
**How to check:**
```bash
# Check for focus-visible on key primitives
for cls in sw-btn sw-input sw-dropdown__trigger sw-menu__item sw-tabs__tab; do
echo -n "$cls: "
grep -c "\.${cls}.*:focus-visible\|\.${cls}:focus-visible" src/css/sw-primitives.css src/css/primitives.css 2>/dev/null || echo "0"
done
# Check for outline:none without replacement
grep -n 'outline.*none\|outline.*0' src/css/*.css | grep -v 'focus-visible\|focus-within'
```
**Result:** PASS if all 5 key primitives have `:focus-visible`. WARN if outline:none exists with adequate replacement. FAIL if missing focus indicators.
---
## Section 8: Component Uniformity
**Pass criteria:**
- `coverage-matrix.md` shows no deprecated component usage (no ⚠ in the deprecated row)
- All surfaces use `sw-*` primitives, not old `.btn-*`, `.toast`, `.popup-menu`
**Files to inspect:**
- `coverage-matrix.md` (generated above)
**How to check:**
```bash
grep '⚠' coverage-matrix.md
```
**Result:** PASS if zero ⚠ markers. FAIL if any deprecated component still in use.
---
## Scoring
| Section | Weight | Result |
|---------|--------|--------|
| 1. Viewport Correctness | Required | |
| 2. Banner Integration | Required | |
| 3. Responsive Behavior | Required | |
| 4. Styling Consistency | Required | |
| 5. Contrast | Required | |
| 6. Touch Targets | Required | |
| 7. Focus Indicators | Required | |
| 8. Component Uniformity | Required | |
**Overall:** PASS requires all sections PASS or WARN. Any FAIL blocks the release.
---
## After the Survey
1. Fix all FAIL items
2. Re-run affected scripts to confirm fixes
3. Re-run the full survey
4. Tag `v0.6.16` only after a clean survey pass

193
docs/WORKFLOWS.md Normal file
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/favicon-16-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

BIN
icons/favicon-16-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

BIN
icons/favicon-32-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

BIN
icons/favicon-32-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
icons/icon-128-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
icons/icon-128-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
icons/icon-128-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
icons/icon-192-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/icon-192-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
icons/icon-192-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/icon-192-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/icon-256-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
icons/icon-256-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
icons/icon-256-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
icons/icon-256-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
icons/icon-48-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

BIN
icons/icon-48-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

BIN
icons/icon-48-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Some files were not shown because too many files have changed in this diff Show More