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>
This commit was merged in pull request #58.
This commit is contained in:
2026-04-02 14:46:54 +00:00
committed by xcaliber
parent 32e4d8725c
commit a7e38bc72a
20 changed files with 1594 additions and 613 deletions

View File

@@ -2,6 +2,43 @@
All notable changes to Armature are documented here. All notable changes to Armature are documented here.
## v0.7.4 — Documentation + Deferred Surface Work
**Docs Category Grouping**
- Backend `Category` field on `docEntry` struct in `server/handlers/docs.go`
- Frontend sidebar groups docs by category with `.docs-category-heading` CSS
- Four categories: Getting Started, Platform, Extension Development, Operations
- 14 docs in ordered list (was 7 ordered + 3 auto-discovered)
**New Documentation (4 guides)**
- `PERMISSIONS-AND-GROUPS.md` — RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions
- `WORKFLOWS.md` — Entry modes, stage modes/types/audiences, signoff gates, SLA enforcement, branch rules, Starlark hooks
- `STARLARK-REFERENCE.md` — Sandbox constraints, 10 modules with function signatures and permission gates, example hook script
- `FRONTEND-JS-GUIDE.md` — Preact+htm runtime, 16 SDK modules with API reference, shell topbar patterns, CSS contract
**Extension Guide Updates**
- `config_section` manifest field documented: schema, backend discovery (`configSectionsForSurface()`), frontend `__CONFIG_SECTIONS__` contract, example component
- Starlark Sandbox API section replaced with pointer to new Starlark Reference
**Docs Content Refresh**
- GETTING-STARTED: `sb_data``armature_data` volume name
- ARCHITECTURE: `sb.register()`/`sb.ns()``sw` SDK references, shell topbar mention
- DEPLOYMENT: `sb_storage``armature_storage`, added `TLS_MODE` env var
- TUTORIAL-FIRST-EXTENSION: `--bg-2``--bg-secondary` CSS variable
- EXTENSION-CSS: self-hosted font notes on `--font` and `--mono`
- `docs.go`: added `AUDIT-` and `USABILITY-` prefix filters for auto-discovery
**Team Admin Workflows Split**
- `workflows.js` (722 lines) split into 3 ES modules:
- `workflows.js` (~160 lines) — `WorkflowsSection` + `WorkflowsTab` + imports
- `workflow-editor.js` (~240 lines) — `WorkflowEditor` + `StageForm`
- `workflow-monitor.js` (~210 lines) — `AssignmentsTab` + `MonitorTab` + `SignoffPanel`
- External import contract unchanged (default export stays in `workflows.js`)
**Bug Fixes**
- Docs outline `scrollToHeading` now scrolls `.docs-content` container instead of `scrollIntoView`, preventing topbar from being pushed off-screen
- `--bg-2` (undefined CSS variable) replaced with `--bg-secondary` in `sw-shell.css` and `sw-primitives.css`
## v0.7.3 — Extension Shell Migration ## v0.7.3 — Extension Shell Migration
**Shell Topbar Migration** **Shell Topbar Migration**
@@ -23,6 +60,26 @@ All notable changes to Armature are documented here.
**Roadmap** **Roadmap**
- Headless E2E automation moved to v0.7.5 (independent from shell migration) - Headless E2E automation moved to v0.7.5 (independent from shell migration)
## v0.7.2 — Package Runners + CI Gate
**Package Runners (5)**
- Notes runner: `requires: ["notes"]`. 3 suites (crud, folders, tags-search), 12 tests
- Chat runner: `requires: ["chat", "chat-core"]`. 2 suites (conversations, messaging), 9 tests
- Schedules runner: `requires: ["schedules"]`. 1 suite (crud), 5 tests
- Workflow runner: `requires: ["content-approval"]`. 1 suite (lifecycle), 5 tests
- Renderer runner: `requires: ["mermaid-renderer"]`. 1 suite (contract), 4 tests
**Runner Result API**
- `POST /api/v1/admin/test-runners/results` — store structured run results
- `GET /api/v1/admin/test-runners/results` — retrieve latest results per runner
- In-memory store with 4 Go handler tests
**CI Integration**
- `test-runners` stage in Gitea CI pipeline
- Playwright driver launches headless browser, navigates to `/s/test-runners`, triggers run-all
- `wait-for-healthy.sh` polls `/healthz/ready` before test execution
- DinD networking fix: resolve container IP via `docker inspect` (port mapping not exposed to runner localhost)
## v0.7.1 — Surface Runner Framework ## v0.7.1 — Surface Runner Framework
**`sw.testing` SDK Module** **`sw.testing` SDK Module**

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.7.3Extension Shell Migration ## Current: v0.7.4Documentation + Deferred Surface Work
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension. storage, realtime, and ops are kernel primitives. Everything else is an extension.
@@ -170,11 +170,15 @@ same pattern as the kernel surface migrations.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Permissions & Groups guide | | RBAC model, permission slugs with descriptions, group scoping, settings cascade. | | Docs category grouping | done | Backend `Category` field on doc entries. Frontend groups sidebar by category with headings. Four categories: Getting Started, Platform, Extension Development, Operations. |
| Workflows user guide | | Entry modes, stages, team roles, signoff gates, SLA, public forms. | | Permissions & Groups guide | done | RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions. |
| Docs content refresh | | Review all 5 existing docs for accuracy at v0.7.x. | | Workflows user guide | done | Entry modes, stages, team roles, signoff gates, SLA, public forms, branch rules, Starlark hooks. |
| Extension config section docs | | Document `config_section` manifest field for Settings/Admin extensibility. | | Starlark Reference | done | Sandbox constraints, 10 modules with function signatures, permission gates, example hook script. |
| Team Admin Workflows evaluation | | 723-line inline designer — extract to surface or split into files. Decision doc if needed. | | Frontend JS Guide | done | Preact+htm runtime, 16 SDK modules with API reference, shell topbar patterns, CSS contract. |
| Extension config_section docs | done | Manifest schema, backend discovery, frontend contract, example component. Added to Extension Guide. |
| Docs content refresh | done | All 10 user-facing docs reviewed for v0.7.x accuracy: rebrand volume names, stale CSS vars, TLS_MODE env, self-hosted font notes, Architecture frontend section. |
| Team Admin Workflows split | done | 722-line `workflows.js` split into 3 modules: `workflows.js` (router+CRUD), `workflow-editor.js` (editor+stages), `workflow-monitor.js` (assignments+monitor+signoff). |
| Stale CSS variable fix | done | `--bg-2` references in `sw-shell.css` and `sw-primitives.css` replaced with `--bg-secondary`. |
### v0.7.5 — Headless E2E + CI Gate ### v0.7.5 — Headless E2E + CI Gate

View File

@@ -1 +1 @@
0.7.3 0.7.4

View File

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

View File

@@ -41,13 +41,13 @@ services:
STORAGE_BACKEND: pvc STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage STORAGE_PATH: /data/storage
volumes: volumes:
- sb_storage:/data/storage - armature_storage:/data/storage
depends_on: depends_on:
- postgres - postgres
volumes: volumes:
pg_data: pg_data:
sb_storage: armature_storage:
``` ```
## Kubernetes ## Kubernetes
@@ -70,6 +70,7 @@ See the `k8s/` directory for example manifests. Key considerations:
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** | | `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
| `ENCRYPTION_KEY` | | AES-256 key for credential vault | | `ENCRYPTION_KEY` | | AES-256 key for credential vault |
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` | | `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
| `STORAGE_BACKEND` | auto | `pvc` or `s3` | | `STORAGE_BACKEND` | auto | `pvc` or `s3` |
| `STORAGE_PATH` | `/data/storage` | PVC mount point | | `STORAGE_PATH` | `/data/storage` | PVC mount point |
| `BASE_PATH` | | URL prefix (e.g., `/armature`) | | `BASE_PATH` | | URL prefix (e.g., `/armature`) |

View File

@@ -147,8 +147,8 @@ Example:
| Variable | Purpose | | Variable | Purpose |
|----------|---------| |----------|---------|
| `--font` | Primary font family | | `--font` | Primary font family (self-hosted, no external requests) |
| `--mono` | Monospace font family | | `--mono` | Monospace font family (self-hosted) |
| `--radius-sm` | Small border-radius (4px) — badges, inline controls | | `--radius-sm` | Small border-radius (4px) — badges, inline controls |
| `--radius` | Default border-radius (8px) — buttons, inputs, cards | | `--radius` | Default border-radius (8px) — buttons, inputs, cards |
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards | | `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |

View File

@@ -79,6 +79,7 @@ Every package has a `manifest.json` at its root. Example for a surface:
| `settings` | no | User-configurable settings schema | | `settings` | no | User-configurable settings schema |
| `exports` | libraries | Functions exported for other packages | | `exports` | libraries | Functions exported for other packages |
| `hooks` | no | Event bus subscriptions | | `hooks` | no | Event bus subscriptions |
| `config_section` | no | Settings/Admin panel injection (see below) |
| `schema_version` | no | Integer for additive schema migrations | | `schema_version` | no | Integer for additive schema migrations |
## db_tables Schema ## db_tables Schema
@@ -138,24 +139,73 @@ Only `path` and `method` are required. All other fields are optional. Malformed
## Starlark Sandbox API ## Starlark Sandbox API
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin): Starlark scripts run server-side with a 1M operation budget and no
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
the complete module catalog, function signatures, and permission gates.
| Module | Permission | API | ## config_section — Settings Panel Injection
|--------|-----------|-----|
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages. Packages can inject configuration panels into the Settings, Admin, or
Team Admin surfaces. Declare `config_section` in `manifest.json`:
```json
{
"config_section": {
"label": "My Config",
"icon": "M12 2L2 7l10 5 10-5-10-5z",
"component": "js/config.js",
"surfaces": ["settings", "admin"],
"category": "system"
}
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `label` | yes | Navigation label shown in the sidebar or tab list |
| `icon` | no | SVG path data for the nav icon |
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
**How it works:**
1. At page load, the backend scans all enabled packages for `config_section`
entries targeting the current surface.
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
3. The frontend dynamically imports the component module and renders it
as an additional tab/section.
4. The component receives `{ packageId, teamId }` as props.
**Example component** (`js/config.js`):
```javascript
const { html } = window;
const { useState, useEffect } = hooks;
export default function MyConfig({ packageId }) {
const [val, setVal] = useState('');
useEffect(() => {
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
}, []);
return html`<div>
<label>API Key</label>
<input value=${val} onInput=${e => setVal(e.target.value)} />
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
</div>`;
}
```
## Permissions Model ## Permissions Model
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions. Extensions declare required permissions in `manifest.json`. The admin
must grant each permission before the extension can use the corresponding
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`. See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
model, user permission slugs, and settings cascade.
## File Structure ## File Structure

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`. Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
Data persists in the `sb_data` named volume. To reset everything: Data persists in the `armature_data` named volume. To reset everything:
```bash ```bash
docker compose down -v docker compose down -v

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.

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

@@ -0,0 +1,232 @@
# 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.
### lib
Load exported functions from library packages.
```python
helpers = lib.require("my-utils")
result = helpers.format_date("2026-01-15")
```
Requirements:
- The library must be declared in your package manifest's `dependencies`.
- The library must be type `library`, status `active`, tier `starlark`.
- Circular dependencies are detected and rejected.
- Results are cached per execution (calling `require` twice returns the
same object).
## 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`.
#### 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
}
```
**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
```
## 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

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

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

View File

@@ -25,17 +25,29 @@ type docEntry struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
} }
// docsOrder defines the display order and metadata for documentation files. // docsOrder defines the display order and metadata for documentation files.
var docsOrder = []docEntry{ var docsOrder = []docEntry{
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide"}, // Getting Started
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components"}, {Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide", Category: "Getting Started"},
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions"}, {Slug: "TUTORIAL-FIRST-EXTENSION", Title: "Tutorial: First Extension", Description: "Step-by-step extension walkthrough", Category: "Getting Started"},
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format"}, // Platform
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview"}, {Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components", Category: "Platform"},
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide"}, {Slug: "PERMISSIONS-AND-GROUPS", Title: "Permissions & Groups", Description: "RBAC model and settings cascade", Category: "Platform"},
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages"}, {Slug: "WORKFLOWS", Title: "Workflows", Description: "Multi-stage processes with team validation", Category: "Platform"},
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide", Category: "Platform"},
// Extension Development
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions", Category: "Extension Development"},
{Slug: "STARLARK-REFERENCE", Title: "Starlark Reference", Description: "Sandbox scripting API", Category: "Extension Development"},
{Slug: "FRONTEND-JS-GUIDE", Title: "Frontend JS Guide", Description: "Preact SDK and shell contract", Category: "Extension Development"},
{Slug: "EXTENSION-CSS", Title: "Extension CSS", Description: "CSS isolation and primitives", Category: "Extension Development"},
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format", Category: "Extension Development"},
// Operations
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview", Category: "Operations"},
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages", Category: "Operations"},
{Slug: "PACKAGE-REGISTRY", Title: "Package Registry", Description: "Registry API and operations", Category: "Operations"},
} }
// ListDocs returns the list of available documentation files. // ListDocs returns the list of available documentation files.
@@ -70,7 +82,8 @@ func (h *DocsHandler) ListDocs(c *gin.Context) {
continue continue
} }
// Skip design docs and other non-user-facing files // Skip design docs and other non-user-facing files
if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") { if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") ||
strings.HasPrefix(slug, "AUDIT-") || strings.HasPrefix(slug, "USABILITY-") {
continue continue
} }
available = append(available, docEntry{ available = append(available, docEntry{

View File

@@ -297,7 +297,7 @@
.sw-inline-error { .sw-inline-error {
display: flex; align-items: center; gap: var(--sp-3); display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
background: var(--bg-2); border: 1px solid var(--danger); background: var(--bg-secondary); border: 1px solid var(--danger);
border-radius: var(--radius); font-size: 13px; color: var(--danger); border-radius: var(--radius); font-size: 13px; color: var(--danger);
} }

View File

@@ -226,7 +226,7 @@
.sw-topbar__tab.active { .sw-topbar__tab.active {
color: var(--text); color: var(--text);
background: var(--bg-2); background: var(--bg-secondary);
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -16,6 +16,17 @@ const { render } = preact;
// Preload marked so renderSync works in useMemo // Preload marked so renderSync works in useMemo
if (sw?.markdown) sw.markdown.preload(); if (sw?.markdown) sw.markdown.preload();
/** Group docs by category, preserving order of first appearance. */
function groupByCategory(docs) {
const map = new Map();
for (const d of docs) {
const cat = d.category || '';
if (!map.has(cat)) map.set(cat, []);
map.get(cat).push(d);
}
return [...map.entries()];
}
function DocsSurface() { function DocsSurface() {
const [docs, setDocs] = useState([]); const [docs, setDocs] = useState([]);
const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED'); const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED');
@@ -81,7 +92,13 @@ function DocsSurface() {
function scrollToHeading(id) { function scrollToHeading(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (!el) return;
const container = document.querySelector('.docs-content');
if (container) {
container.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} }
// ── Rendered HTML via sw.markdown ── // ── Rendered HTML via sw.markdown ──
@@ -122,13 +139,18 @@ function DocsSurface() {
<p>Failed to load docs list.</p> <p>Failed to load docs list.</p>
<button onclick=${retryList} class="docs-retry-btn">Retry</button> <button onclick=${retryList} class="docs-retry-btn">Retry</button>
</div> </div>
` : docs.map(d => html` ` : groupByCategory(docs).map(([cat, items]) => html`
<a key=${d.slug} <div key=${cat} class="docs-category-group">
class="docs-nav-item ${d.slug === active ? 'active' : ''}" ${cat && html`<div class="docs-category-heading">${cat}</div>`}
onclick=${() => navigate(d.slug)} ${items.map(d => html`
href="javascript:void(0)"> <a key=${d.slug}
${d.title} class="docs-nav-item ${d.slug === active ? 'active' : ''}"
</a> onclick=${() => navigate(d.slug)}
href="javascript:void(0)">
${d.title}
</a>
`)}
</div>
`)} `)}
</nav> </nav>
<main class="docs-content"> <main class="docs-content">
@@ -184,6 +206,16 @@ style.textContent = `
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.docs-category-group { margin-bottom: 4px; }
.docs-category-heading {
padding: 8px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.docs-category-group:first-child .docs-category-heading { padding-top: 0; }
.docs-nav-item { .docs-nav-item {
display: block; display: block;
padding: 6px 10px; padding: 6px 10px;

View File

@@ -0,0 +1,337 @@
/**
* Workflow Editor + Stage Form
*
* Extracted from workflows.js — inline editing for a single workflow
* definition and its stages (modes, audiences, SLA, validation, branches).
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
const AUDIENCES = ['team', 'public', 'system'];
// ── Workflow Editor (with Stage Editor) ─────
export function WorkflowEditor({ teamId, workflow, onBack }) {
const [stages, setStages] = useState([]);
const [teams, setTeams] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const loadStages = useCallback(async () => {
try {
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
setStages(s || []);
setTeams(sw.auth?.teams || []);
} catch (e) { sw.toast(e.message, 'error'); }
}, [teamId, workflow.id]);
useEffect(() => { loadStages(); }, [loadStages]);
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
const patch = {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
};
const sth = form.staleness_timeout_hours.value.trim();
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
sw.toast('Workflow updated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow() {
try {
await sw.api.teams.publishWorkflow(teamId, workflow.id);
sw.toast('Workflow published', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow() {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, workflow.id);
sw.toast('Workflow deleted', 'success');
onBack();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function addStage(data) {
try {
await sw.api.teams.createWorkflowStage(teamId, workflow.id, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateStage(stageId, data) {
try {
await sw.api.teams.updateWorkflowStage(teamId, workflow.id, stageId, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflowStage(teamId, workflow.id, stageId);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
<div style="flex:1"></div>
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" onClick=${deleteWorkflow}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${workflow.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${workflow.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
<input type="number" name="staleness_timeout_hours" min="0"
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
</div>
</div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${workflow.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
${workflow.entry_mode === 'public_link' && html`
<div style="margin:8px 0;">
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
onClick=${e => e.target.select()} />
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
navigator.clipboard.writeText(url).then(() => {
e.target.textContent = 'Copied!';
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
});
}}>Copy</button>
</div>
</div>
`}
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Save</button>
</div>
</form>
<!-- Stage Editor -->
<div style="margin-top:24px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h5 style="margin:0;">Stages (${stages.length})</h5>
<div style="flex:1"></div>
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage('new')}>+ Add Stage</button>
</div>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
<span class="badge">${s.audience || 'team'}</span>
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteStage(s.id)}>\u00d7</button>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
${editingStage && html`
<${StageForm}
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
teams=${teams}
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
onCancel=${() => setEditingStage(null)}
/>
`}
</div>
`;
}
// ── Stage Form ──────────────────────────────
function StageForm({ stage, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'form');
const [audience, setAudience] = useState(stage?.audience || 'team');
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
const [branchRules, setBranchRules] = useState(
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
);
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
useEffect(() => {
if (!assignTeam) return;
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
}, [assignTeam]);
function submit() {
const stageConfig = {};
if (requiredRole) stageConfig.required_role = requiredRole;
if (valApprovals) {
stageConfig.validation = {
required_approvals: parseInt(valApprovals, 10),
...(valRole ? { required_role: valRole } : {}),
reject_action: valReject || 'cancel',
};
}
let parsedBranch = null;
if (branchRules.trim()) {
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
}
onSave({
name,
stage_mode: mode,
audience,
stage_type: stageType,
starlark_hook: starlarkHook || null,
assignment_team_id: assignTeam || null,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
branch_rules: parsedBranch || [],
});
}
return html`
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input value=${name} onInput=${e => setName(e.target.value)} />
</div>
<div class="form-group">
<label>Mode</label>
<select value=${mode} onChange=${e => setMode(e.target.value)}>
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Audience</label>
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
</select>
</div>
<div class="form-group">
<label>Stage Type</label>
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
</select>
</div>
</div>
${stageType !== 'simple' && html`
<div class="form-row">
<div class="form-group" style="flex:1;">
<label>Starlark Hook</label>
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
placeholder="package_id:entry_point" />
</div>
</div>
`}
<div class="form-row">
<div class="form-group">
<label>Queue to Team</label>
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
<option value="">\u2014 none (visitor stage) \u2014</option>
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
</select>
</div>
<div class="form-group">
<label>SLA (seconds)</label>
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
${assignTeam && html`
<div class="form-row">
<div class="form-group">
<label>Required Role (claim)</label>
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
<strong style="font-size:12px;">Multi-party Validation</strong>
<div class="form-row" style="margin-top:4px;">
<div class="form-group">
<label>Required Approvals</label>
<input type="number" min="0" value=${valApprovals}
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
</div>
<div class="form-group">
<label>Signoff Role</label>
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
<div class="form-group">
<label>On Reject</label>
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
<option value="cancel">Cancel instance</option>
</select>
</div>
</div>
</div>
`}
<details style="margin-top:8px;">
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
<div class="form-group" style="margin-top:4px;">
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
</div>
</details>
<label class="toggle-label">
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
<span class="toggle-track"></span><span>Auto-advance when complete</span>
</label>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="button" class="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
${stage ? 'Update' : 'Add Stage'}
</button>
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,239 @@
/**
* Workflow Assignments, Monitor, and Signoff
*
* Extracted from workflows.js — runtime views for active workflow
* instances: assignment queue, instance monitoring, and signoff panel.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function _timeAgo(ts) {
if (!ts) return '';
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function _formatDuration(seconds) {
if (!seconds || seconds < 0) return '\u2014';
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
return `${Math.floor(hrs / 24)}d ${hrs % 24}h`;
}
// ── Tab 2: Assignments ──────────────────────
export function AssignmentsTab({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
// WS live updates
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
const off3 = sw.on('workflow.unclaimed', load);
return () => { off1(); off2(); off3(); };
}, [teamId]);
async function claim(id) {
try {
await sw.api.workflowAssignments.claim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim(id) {
try {
await sw.api.workflowAssignments.unclaim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete(id) {
try {
await sw.api.workflowAssignments.complete(id);
sw.toast('Assignment completed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const userId = sw.auth?.user?.id;
return html`
<div>
<h4 style="margin:0 0 8px;">My Active (${claimed.length})</h4>
<div class="admin-list">
${claimed.map(a => html`
<div class="admin-surface-row" key=${a.id} style="border-left:3px solid var(--accent-1);">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
</div>
<div style="display:flex;gap:4px;">
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
<button class="sw-btn sw-btn--primary sw-btn--sm"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => complete(a.id)}>Complete</button>
</div>
</div>
`)}
${claimed.length === 0 && html`<div class="empty-hint">No claimed assignments</div>`}
</div>
<h4 style="margin:16px 0 8px;">Available (${unassigned.length})</h4>
<div class="admin-list">
${unassigned.map(a => html`
<div class="admin-surface-row" key=${a.id}>
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
</div>
`)}
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
</div>
</div>
`;
}
// ── Tab 3: Monitor ──────────────────────────
export function MonitorTab({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedSignoff, setExpandedSignoff] = useState(null);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function cancelInstance(channelId) {
const ok = await sw.confirm('Cancel this workflow instance? All open assignments will be cancelled.', true);
if (!ok) return;
try {
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<h4 style="margin:0 0 8px;">Active Instances (${instances.length})</h4>
<div class="admin-list">
${instances.map(inst => html`
<div class="admin-surface-row" key=${inst.channel_id}>
<div style="flex:1;min-width:0;">
<strong>${inst.workflow_name}</strong>
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
${inst.sla_breached && html`<span class="badge badge-danger">SLA breached</span>`}
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
</div>
<div style="display:flex;gap:4px;">
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
</div>
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
</div>
</div>
`;
}
// ── Signoff Panel ──────────────────
function SignoffPanel({ instanceId, teamId }) {
const [signoffs, setSignoffs] = useState([]);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState('');
const load = useCallback(async () => {
try {
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
setSignoffs(resp.data || []);
} catch { setSignoffs([]); }
finally { setLoading(false); }
}, [instanceId]);
useEffect(() => { load(); }, [load]);
async function submit(decision) {
try {
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
setComment('');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
const approvals = signoffs.filter(s => s.decision === 'approve').length;
const rejections = signoffs.filter(s => s.decision === 'reject').length;
return html`
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
</div>
<div style="display:flex;gap:4px;align-items:center;">
<input value=${comment} onInput=${e => setComment(e.target.value)}
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
</div>
${signoffs.length > 0 && html`
<div style="margin-top:6px;">
${signoffs.map(s => html`
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
</div>
`)}
</div>
`}
</div>
`;
}

View File

@@ -2,38 +2,21 @@
* Team Admin > Workflows * Team Admin > Workflows
* *
* Tab layout: Workflows | Assignments | Monitor * Tab layout: Workflows | Assignments | Monitor
* - Workflows: CRUD + inline stage editor (E2) * - Workflows: CRUD + inline stage editor → workflow-editor.js
* - Assignments: Team assignment queue (E1) * - Assignments: Team assignment queue → workflow-monitor.js
* - Monitor: Active instance list with cancel (E3) * - Monitor: Active instance list with cancel → workflow-monitor.js
*/ */
import { WorkflowEditor } from './workflow-editor.js';
import { AssignmentsTab, MonitorTab } from './workflow-monitor.js';
const { html } = window; const { html } = window;
const { useState, useEffect, useCallback } = hooks; const { useState, useCallback, useEffect } = hooks;
const ENTRY_MODES = ['public_link', 'team_only']; const ENTRY_MODES = ['public_link', 'team_only'];
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
const AUDIENCES = ['team', 'public', 'system'];
const TABS = ['Workflows', 'Assignments', 'Monitor']; const TABS = ['Workflows', 'Assignments', 'Monitor'];
function _timeAgo(ts) { function _unwrapList(resp) {
if (!ts) return ''; return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function _formatDuration(seconds) {
if (!seconds || seconds < 0) return '—';
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
return `${Math.floor(hrs / 24)}d ${hrs % 24}h`;
} }
// ── Main Section ──────────────────────────── // ── Main Section ────────────────────────────
@@ -59,10 +42,6 @@ export default function WorkflowsSection({ teamId }) {
// ── Tab 1: Workflows ──────────────────────── // ── Tab 1: Workflows ────────────────────────
function _unwrapList(resp) {
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
}
function WorkflowsTab({ teamId }) { function WorkflowsTab({ teamId }) {
const [workflows, setWorkflows] = useState([]); const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -186,537 +165,3 @@ function WorkflowsTab({ teamId }) {
</div> </div>
`; `;
} }
// ── Workflow Editor (with Stage Editor) ─────
function WorkflowEditor({ teamId, workflow, onBack }) {
const [stages, setStages] = useState([]);
const [teams, setTeams] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const loadStages = useCallback(async () => {
try {
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
setStages(s || []);
setTeams(sw.auth?.teams || []);
} catch (e) { sw.toast(e.message, 'error'); }
}, [teamId, workflow.id]);
useEffect(() => { loadStages(); }, [loadStages]);
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
const patch = {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
};
const sth = form.staleness_timeout_hours.value.trim();
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
sw.toast('Workflow updated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow() {
try {
await sw.api.teams.publishWorkflow(teamId, workflow.id);
sw.toast('Workflow published', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow() {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, workflow.id);
sw.toast('Workflow deleted', 'success');
onBack();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function addStage(data) {
try {
await sw.api.teams.createWorkflowStage(teamId, workflow.id, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateStage(stageId, data) {
try {
await sw.api.teams.updateWorkflowStage(teamId, workflow.id, stageId, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflowStage(teamId, workflow.id, stageId);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
<div style="flex:1"></div>
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" onClick=${deleteWorkflow}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${workflow.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${workflow.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
<input type="number" name="staleness_timeout_hours" min="0"
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
</div>
</div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${workflow.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
${workflow.entry_mode === 'public_link' && html`
<div style="margin:8px 0;">
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
onClick=${e => e.target.select()} />
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
navigator.clipboard.writeText(url).then(() => {
e.target.textContent = 'Copied!';
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
});
}}>Copy</button>
</div>
</div>
`}
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Save</button>
</div>
</form>
<!-- Stage Editor -->
<div style="margin-top:24px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h5 style="margin:0;">Stages (${stages.length})</h5>
<div style="flex:1"></div>
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage('new')}>+ Add Stage</button>
</div>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
<span class="badge">${s.audience || 'team'}</span>
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteStage(s.id)}>\u00d7</button>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
${editingStage && html`
<${StageForm}
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
teams=${teams}
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
onCancel=${() => setEditingStage(null)}
/>
`}
</div>
`;
}
// ── Stage Form ──────────────────────────────
function StageForm({ stage, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'form');
const [audience, setAudience] = useState(stage?.audience || 'team');
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
const [branchRules, setBranchRules] = useState(
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
);
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
useEffect(() => {
if (!assignTeam) return;
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
}, [assignTeam]);
function submit() {
const stageConfig = {};
if (requiredRole) stageConfig.required_role = requiredRole;
if (valApprovals) {
stageConfig.validation = {
required_approvals: parseInt(valApprovals, 10),
...(valRole ? { required_role: valRole } : {}),
reject_action: valReject || 'cancel',
};
}
let parsedBranch = null;
if (branchRules.trim()) {
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
}
onSave({
name,
stage_mode: mode,
audience,
stage_type: stageType,
starlark_hook: starlarkHook || null,
assignment_team_id: assignTeam || null,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
branch_rules: parsedBranch || [],
});
}
return html`
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input value=${name} onInput=${e => setName(e.target.value)} />
</div>
<div class="form-group">
<label>Mode</label>
<select value=${mode} onChange=${e => setMode(e.target.value)}>
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Audience</label>
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
</select>
</div>
<div class="form-group">
<label>Stage Type</label>
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
</select>
</div>
</div>
${stageType !== 'simple' && html`
<div class="form-row">
<div class="form-group" style="flex:1;">
<label>Starlark Hook</label>
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
placeholder="package_id:entry_point" />
</div>
</div>
`}
<div class="form-row">
<div class="form-group">
<label>Queue to Team</label>
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
<option value="">\u2014 none (visitor stage) \u2014</option>
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
</select>
</div>
<div class="form-group">
<label>SLA (seconds)</label>
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
${assignTeam && html`
<div class="form-row">
<div class="form-group">
<label>Required Role (claim)</label>
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
<strong style="font-size:12px;">Multi-party Validation</strong>
<div class="form-row" style="margin-top:4px;">
<div class="form-group">
<label>Required Approvals</label>
<input type="number" min="0" value=${valApprovals}
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
</div>
<div class="form-group">
<label>Signoff Role</label>
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
<option value="">\u2014 any member \u2014</option>
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
<div class="form-group">
<label>On Reject</label>
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
<option value="cancel">Cancel instance</option>
</select>
</div>
</div>
</div>
`}
<details style="margin-top:8px;">
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
<div class="form-group" style="margin-top:4px;">
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
</div>
</details>
<label class="toggle-label">
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
<span class="toggle-track"></span><span>Auto-advance when complete</span>
</label>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="button" class="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
${stage ? 'Update' : 'Add Stage'}
</button>
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
</div>
</div>
`;
}
// ── Tab 2: Assignments ──────────────────────
function AssignmentsTab({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
// WS live updates
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
const off3 = sw.on('workflow.unclaimed', load);
return () => { off1(); off2(); off3(); };
}, [teamId]);
async function claim(id) {
try {
await sw.api.workflowAssignments.claim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim(id) {
try {
await sw.api.workflowAssignments.unclaim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete(id) {
try {
await sw.api.workflowAssignments.complete(id);
sw.toast('Assignment completed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const userId = sw.auth?.user?.id;
return html`
<div>
<h4 style="margin:0 0 8px;">My Active (${claimed.length})</h4>
<div class="admin-list">
${claimed.map(a => html`
<div class="admin-surface-row" key=${a.id} style="border-left:3px solid var(--accent-1);">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
</div>
<div style="display:flex;gap:4px;">
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
<button class="sw-btn sw-btn--primary sw-btn--sm"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => complete(a.id)}>Complete</button>
</div>
</div>
`)}
${claimed.length === 0 && html`<div class="empty-hint">No claimed assignments</div>`}
</div>
<h4 style="margin:16px 0 8px;">Available (${unassigned.length})</h4>
<div class="admin-list">
${unassigned.map(a => html`
<div class="admin-surface-row" key=${a.id}>
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
</div>
`)}
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
</div>
</div>
`;
}
// ── Tab 3: Monitor ──────────────────────────
function MonitorTab({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedSignoff, setExpandedSignoff] = useState(null);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function cancelInstance(channelId) {
const ok = await sw.confirm('Cancel this workflow instance? All open assignments will be cancelled.', true);
if (!ok) return;
try {
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<h4 style="margin:0 0 8px;">Active Instances (${instances.length})</h4>
<div class="admin-list">
${instances.map(inst => html`
<div class="admin-surface-row" key=${inst.channel_id}>
<div style="flex:1;min-width:0;">
<strong>${inst.workflow_name}</strong>
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
${inst.sla_breached && html`<span class="badge badge-danger">SLA breached</span>`}
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
</div>
<div style="display:flex;gap:4px;">
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
</div>
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
</div>
</div>
`;
}
// ── Signoff Panel ──────────────────
function SignoffPanel({ instanceId, teamId }) {
const [signoffs, setSignoffs] = useState([]);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState('');
const load = useCallback(async () => {
try {
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
setSignoffs(resp.data || []);
} catch { setSignoffs([]); }
finally { setLoading(false); }
}, [instanceId]);
useEffect(() => { load(); }, [load]);
async function submit(decision) {
try {
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
setComment('');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
const approvals = signoffs.filter(s => s.decision === 'approve').length;
const rejections = signoffs.filter(s => s.decision === 'reject').length;
return html`
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
</div>
<div style="display:flex;gap:4px;align-items:center;">
<input value=${comment} onInput=${e => setComment(e.target.value)}
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
</div>
${signoffs.length > 0 && html`
<div style="margin-top:6px;">
${signoffs.map(s => html`
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
</div>
`)}
</div>
`}
</div>
`;
}