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.
## 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
**Shell Topbar Migration**
@@ -23,6 +60,26 @@ All notable changes to Armature are documented here.
**Roadmap**
- 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
**`sw.testing` SDK Module**

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap
## Current: v0.7.3Extension Shell Migration
## Current: v0.7.4Documentation + Deferred Surface Work
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
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 |
|------|--------|-------------|
| Permissions & Groups guide | | RBAC model, permission slugs with descriptions, group scoping, settings cascade. |
| Workflows user guide | | Entry modes, stages, team roles, signoff gates, SLA, public forms. |
| Docs content refresh | | Review all 5 existing docs for accuracy at v0.7.x. |
| Extension config section docs | | Document `config_section` manifest field for Settings/Admin extensibility. |
| Team Admin Workflows evaluation | | 723-line inline designer — extract to surface or split into files. Decision doc if needed. |
| 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. |
| Permissions & Groups guide | done | RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions. |
| Workflows user guide | done | Entry modes, stages, team roles, signoff gates, SLA, public forms, branch rules, Starlark hooks. |
| Starlark Reference | done | Sandbox constraints, 10 modules with function signatures, permission gates, example hook script. |
| 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

View File

@@ -1 +1 @@
0.7.3
0.7.4

View File

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

View File

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

View File

@@ -147,8 +147,8 @@ Example:
| Variable | Purpose |
|----------|---------|
| `--font` | Primary font family |
| `--mono` | Monospace font family |
| `--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 |

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 |
| `exports` | libraries | Functions exported for other packages |
| `hooks` | no | Event bus subscriptions |
| `config_section` | no | Settings/Admin panel injection (see below) |
| `schema_version` | no | Integer for additive schema migrations |
## db_tables Schema
@@ -138,24 +139,73 @@ Only `path` and `method` are required. All other fields are optional. Malformed
## 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

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

193
docs/WORKFLOWS.md Normal file
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"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
}
// docsOrder defines the display order and metadata for documentation files.
var docsOrder = []docEntry{
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide"},
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components"},
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions"},
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format"},
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview"},
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide"},
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages"},
// Getting Started
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide", Category: "Getting Started"},
{Slug: "TUTORIAL-FIRST-EXTENSION", Title: "Tutorial: First Extension", Description: "Step-by-step extension walkthrough", Category: "Getting Started"},
// Platform
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components", Category: "Platform"},
{Slug: "PERMISSIONS-AND-GROUPS", Title: "Permissions & Groups", Description: "RBAC model and settings cascade", Category: "Platform"},
{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.
@@ -70,7 +82,8 @@ func (h *DocsHandler) ListDocs(c *gin.Context) {
continue
}
// 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
}
available = append(available, docEntry{

View File

@@ -297,7 +297,7 @@
.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);
background: var(--bg-secondary); border: 1px solid var(--danger);
border-radius: var(--radius); font-size: 13px; color: var(--danger);
}

View File

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

View File

@@ -16,6 +16,17 @@ const { render } = preact;
// Preload marked so renderSync works in useMemo
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() {
const [docs, setDocs] = useState([]);
const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED');
@@ -81,7 +92,13 @@ function DocsSurface() {
function scrollToHeading(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 ──
@@ -122,13 +139,18 @@ function DocsSurface() {
<p>Failed to load docs list.</p>
<button onclick=${retryList} class="docs-retry-btn">Retry</button>
</div>
` : docs.map(d => html`
<a key=${d.slug}
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
onclick=${() => navigate(d.slug)}
href="javascript:void(0)">
${d.title}
</a>
` : groupByCategory(docs).map(([cat, items]) => html`
<div key=${cat} class="docs-category-group">
${cat && html`<div class="docs-category-heading">${cat}</div>`}
${items.map(d => html`
<a key=${d.slug}
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
onclick=${() => navigate(d.slug)}
href="javascript:void(0)">
${d.title}
</a>
`)}
</div>
`)}
</nav>
<main class="docs-content">
@@ -184,6 +206,16 @@ style.textContent = `
text-transform: uppercase;
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 {
display: block;
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
*
* Tab layout: Workflows | Assignments | Monitor
* - Workflows: CRUD + inline stage editor (E2)
* - Assignments: Team assignment queue (E1)
* - Monitor: Active instance list with cancel (E3)
* - Workflows: CRUD + inline stage editor → workflow-editor.js
* - Assignments: Team assignment queue → workflow-monitor.js
* - 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 { useState, useEffect, useCallback } = hooks;
const { useState, useCallback, useEffect } = 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'];
const TABS = ['Workflows', 'Assignments', 'Monitor'];
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 '—';
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`;
function _unwrapList(resp) {
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
}
// ── Main Section ────────────────────────────
@@ -59,10 +42,6 @@ export default function WorkflowsSection({ teamId }) {
// ── Tab 1: Workflows ────────────────────────
function _unwrapList(resp) {
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
}
function WorkflowsTab({ teamId }) {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
@@ -186,537 +165,3 @@ function WorkflowsTab({ teamId }) {
</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>
`;
}