Changeset 0.37.15 (#227)

This commit is contained in:
2026-03-23 19:58:17 +00:00
parent b7746c3004
commit d005e8a30f
23 changed files with 1764 additions and 170 deletions

View File

@@ -1,5 +1,53 @@
# Changelog
## [0.37.15.0] — 2026-03-23
### Summary
Workflow Ownership & Lifecycle (CS1CS5). Adds instance cancel with assignment
cascade, assignment unclaim/reassign/cancel operations, full FE API domain
wiring for stage CRUD and assignments, team-admin workflows rewrite with
tabbed layout (Workflows + Stage Editor | Assignments Queue | Instance Monitor),
and settings assignments view replacing the read-only workflow list.
### Added
- **Instance cancel:** `POST /channels/:id/workflow/cancel` sets
`workflow_status='cancelled'` and cascades to all open assignments. Auth:
instance owner, team admin, or global admin. Team-scoped variant at
`POST /teams/:teamId/workflows/monitor/instances/:channelId/cancel`.
(`server/handlers/workflow_instances.go`, `server/store/*/channel.go`)
- **Assignment unclaim:** `POST /workflow-assignments/:id/unclaim` returns
claimed assignment to unassigned. Auth: claimer or team admin.
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **Assignment reassign:** `POST /workflow-assignments/:id/reassign` changes
`assigned_to` on claimed assignment. Auth: team admin.
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **Assignment cancel:** `POST /workflow-assignments/:id/cancel` cancels a
single assignment. Auth: team admin.
(`server/handlers/workflow_assignments.go`, `server/store/*/workflows.go`)
- **FE API domains:** `workflowAssignments` domain (mine, claim, unclaim,
complete, reassign, cancel, comment). Team domain additions: assignments,
workflowInstances, cancelWorkflowInstance, stage CRUD (workflowStages,
createWorkflowStage, updateWorkflowStage, deleteWorkflowStage,
reorderWorkflowStages). (`src/js/sw/sdk/api-domains.js`)
- **Team-admin workflows tabs:** Tabbed layout (Workflows | Assignments |
Monitor). Workflows tab includes inline stage editor with add/edit/delete.
Assignments tab shows claimed + available queue with claim/release/complete
actions and WS live updates. Monitor tab shows active instances with cancel.
(`src/js/sw/surfaces/team-admin/workflows.js`)
- **Settings assignments:** Replaces read-only workflow list with personal
assignment queue across all teams. Shows claimed and available assignments
with claim/release/open/complete actions.
(`src/js/sw/surfaces/settings/workflows.js`)
### Changed
- Settings sidebar label "Workflows" → "Assignments".
(`src/js/sw/surfaces/settings/index.js`)
---
## [0.37.14.23] — 2026-03-23
### Summary

View File

@@ -1 +1 @@
0.37.14.23
0.37.15.0

View File

@@ -32,11 +32,13 @@ build_package() {
local out="$DIST_DIR/${name}.pkg"
rm -f "$out"
# Zip whichever standard dirs exist alongside manifest.json
# Zip whichever standard dirs/files exist alongside manifest.json
local dirs=""
[ -d "$dir/js" ] && dirs="$dirs js/"
[ -d "$dir/css" ] && dirs="$dirs css/"
[ -d "$dir/assets" ] && dirs="$dirs assets/"
[ -f "$dir/script.star" ] && dirs="$dirs script.star"
[ -d "$dir/migrations" ] && dirs="$dirs migrations/"
(cd "$dir" && zip -qr "$out" manifest.json $dirs)

View File

@@ -0,0 +1,224 @@
/* Team Activity Log — Surface Styles
* Uses platform CSS variables exclusively for theming.
*/
.tal-shell {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
/* ── Header ────────────────────────────────── */
.tal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tal-header__left {
display: flex;
align-items: baseline;
gap: 10px;
}
.tal-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.tal-subtitle {
font-size: 13px;
color: var(--text-3);
}
/* ── Entry Form ────────────────────────────── */
.tal-form {
display: flex;
gap: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.tal-form__select {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 6px 8px;
cursor: pointer;
}
.tal-form__input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 14px;
padding: 6px 10px;
outline: none;
transition: border-color var(--transition);
}
.tal-form__input:focus {
border-color: var(--accent);
}
.tal-form__input::placeholder {
color: var(--text-3);
}
.tal-form__btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-family: var(--font);
font-size: 13px;
font-weight: 500;
padding: 6px 16px;
cursor: pointer;
transition: background var(--transition);
}
.tal-form__btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.tal-form__btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Filters ───────────────────────────────── */
.tal-filters {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tal-filter {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-2);
font-family: var(--font);
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
transition: all var(--transition);
}
.tal-filter:hover {
background: var(--bg-hover);
color: var(--text);
}
.tal-filter.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.tal-count {
margin-left: auto;
font-size: 12px;
color: var(--text-3);
}
/* ── Entry List ────────────────────────────── */
.tal-entries {
display: flex;
flex-direction: column;
gap: 6px;
}
.tal-entry {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
transition: border-color var(--transition);
}
.tal-entry:hover {
border-color: var(--border-light);
}
.tal-entry__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.tal-entry__category {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.tal-entry__user {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.tal-entry__time {
font-size: 12px;
color: var(--text-3);
margin-left: auto;
}
.tal-entry__delete {
background: none;
border: none;
color: var(--text-3);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: all var(--transition);
}
.tal-entry:hover .tal-entry__delete {
opacity: 1;
}
.tal-entry__delete:hover {
color: var(--danger);
}
.tal-entry__message {
font-size: 14px;
color: var(--text);
line-height: 1.5;
}
/* ── Empty / Loading ───────────────────────── */
.tal-empty,
.tal-loading {
text-align: center;
padding: 40px 16px;
color: var(--text-3);
font-size: 14px;
}

View File

@@ -0,0 +1,221 @@
/**
* Team Activity Log — Surface Entry Point
*
* Demonstrates:
* - SDK boot + sw.* API access
* - Preact rendering into #extension-mount
* - Calling Starlark-backed ext API routes (/s/:slug/api/*)
* - sw.toast, sw.auth, sw.on (WS event subscription)
* - sw.userMenu for shell integration
* - Platform CSS variables for theming
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
// ── Boot SDK (extension surfaces don't auto-load it) ──
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
var slug = (window.__MANIFEST__ || {}).id || 'team-activity-log';
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks;
var { render } = preact;
// ── API helper: call Starlark backend ──
var API_BASE = base + '/s/' + slug + '/api';
async function api(method, path, body) {
var token = sw.auth._getToken();
var opts = {
method: method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
var resp = await fetch(API_BASE + path, opts);
var text = await resp.text();
try { return JSON.parse(text); } catch (_) { return { _raw: text, _status: resp.status }; }
}
// ── Components ─────────────────────────────
var CATEGORIES = ['note', 'update', 'decision', 'action-item'];
function App() {
var [entries, setEntries] = useState([]);
var [loading, setLoading] = useState(true);
var [filter, setFilter] = useState('');
var menuRef = useRef(null);
var load = useCallback(async function () {
var path = '/entries' + (filter ? '?category=' + encodeURIComponent(filter) : '');
var d = await api('GET', path);
setEntries(d.data || []);
setLoading(false);
}, [filter]);
useEffect(function () { load(); }, [load]);
// Mount user menu
useEffect(function () {
if (menuRef.current && sw.userMenu) {
sw.userMenu(menuRef.current, { placement: 'down-right' });
}
}, []);
async function addEntry(category, message) {
var user = sw.auth.user;
var result = await api('POST', '/entries', {
category: category,
message: message,
username: user ? (user.display_name || user.username) : '',
created_at: new Date().toISOString(),
});
if (result.error) {
sw.toast(result.error, 'error');
return;
}
sw.toast('Entry added', 'success');
load();
}
async function deleteEntry(id) {
await api('DELETE', '/entries/' + id);
sw.toast('Entry deleted', 'info');
load();
}
return html`
<div class="tal-shell">
<header class="tal-header">
<div class="tal-header__left">
<h1 class="tal-title">Activity Log</h1>
<span class="tal-subtitle">Team activity tracker</span>
</div>
<div class="tal-header__right">
<div ref=${menuRef}></div>
</div>
</header>
<${EntryForm} onSubmit=${addEntry} />
<div class="tal-filters">
<button class="tal-filter ${filter === '' ? 'active' : ''}"
onClick=${() => setFilter('')}>All</button>
${CATEGORIES.map(c => html`
<button key=${c}
class="tal-filter ${filter === c ? 'active' : ''}"
onClick=${() => setFilter(c)}>
${c}
</button>
`)}
<span class="tal-count">${entries.length} entries</span>
</div>
${loading
? html`<div class="tal-loading">Loading…</div>`
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
}
</div>
`;
}
function EntryForm({ onSubmit }) {
var [message, setMessage] = useState('');
var [category, setCategory] = useState('note');
var [submitting, setSubmitting] = useState(false);
async function submit(e) {
e.preventDefault();
if (!message.trim()) return;
setSubmitting(true);
await onSubmit(category, message.trim());
setMessage('');
setSubmitting(false);
}
return html`
<form class="tal-form" onSubmit=${submit}>
<select class="tal-form__select" value=${category}
onChange=${e => setCategory(e.target.value)}>
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
</select>
<input class="tal-form__input" type="text"
placeholder="What happened?"
value=${message}
onInput=${e => setMessage(e.target.value)}
disabled=${submitting} />
<button class="tal-form__btn" type="submit" disabled=${submitting || !message.trim()}>
${submitting ? '…' : 'Log'}
</button>
</form>
`;
}
function EntryList({ entries, onDelete }) {
if (!entries.length) {
return html`<div class="tal-empty">No entries yet. Log your first activity above.</div>`;
}
return html`
<div class="tal-entries">
${entries.map(e => html`
<div key=${e.id} class="tal-entry">
<div class="tal-entry__header">
<span class="tal-entry__category badge">${e.category}</span>
<span class="tal-entry__user">${esc(e.username || 'unknown')}</span>
<span class="tal-entry__time">${timeAgo(e.created_at)}</span>
<button class="tal-entry__delete" onClick=${() => onDelete(e.id)}
title="Delete">×</button>
</div>
<div class="tal-entry__message">${esc(e.message)}</div>
</div>
`)}
</div>
`;
}
// ── Utilities ──────────────────────────────
function esc(s) {
var el = document.createElement('span');
el.textContent = String(s || '');
return el.innerHTML;
}
function timeAgo(iso) {
if (!iso) return '';
var ms = Date.now() - new Date(iso).getTime();
var sec = Math.floor(ms / 1000);
if (sec < 60) return 'just now';
var min = Math.floor(sec / 60);
if (min < 60) return min + 'm ago';
var hr = Math.floor(min / 60);
if (hr < 24) return hr + 'h ago';
var d = Math.floor(hr / 24);
return d + 'd ago';
}
// ── Mount ──────────────────────────────────
render(html`<${App} />`, mount);
console.log('[team-activity-log] Surface mounted');
})();

View File

@@ -0,0 +1,48 @@
{
"id": "team-activity-log",
"title": "Team Activity Log",
"type": "full",
"tier": "starlark",
"route": "/s/team-activity-log",
"auth": "authenticated",
"layout": "single",
"version": "0.1.0",
"description": "Example surface demonstrating Starlark API routes, extension-owned DB tables, admin settings, and SDK integration.",
"author": "switchboard",
"permissions": ["api.http", "db.read", "db.write"],
"api_routes": [
{"method": "GET", "path": "/entries"},
{"method": "POST", "path": "/entries"},
{"method": "DELETE", "path": "/entries/*"}
],
"db_tables": [
{
"name": "entries",
"columns": {
"user_id": "text",
"username": "text",
"category": "text",
"message": "text",
"created_at": "text"
}
}
],
"settings": {
"max_entries": {
"type": "number",
"label": "Max Entries",
"description": "Maximum number of entries to retain (oldest are pruned).",
"default": 500
},
"categories": {
"type": "string",
"label": "Categories (comma-separated)",
"description": "Allowed activity categories. Leave blank for freeform.",
"default": "note,update,decision,action-item"
}
}
}

View File

@@ -0,0 +1,186 @@
# Team Activity Log — Starlark Backend
#
# Handles:
# GET /entries → list (optional ?category= filter)
# POST /entries → create
# DELETE /entries/:id → delete
#
# NOTE: The sandbox has no json module. Request body arrives as a raw
# string; response body must be a raw string. Helper functions build
# JSON manually. A json.encode/decode module would clean this up —
# tracked as a sandbox improvement.
def on_request(req):
method = req["method"]
path = req["path"]
if method == "GET" and path == "/entries":
return _list_entries(req)
elif method == "POST" and path == "/entries":
return _create_entry(req)
elif method == "DELETE" and path.startswith("/entries/"):
entry_id = path[len("/entries/"):]
return _delete_entry(req, entry_id)
return {"status": 404, "body": '{"error":"not found"}'}
def _list_entries(req):
category = ""
q = req.get("query", None)
if q != None:
cv = q.get("category", None)
if cv != None:
category = str(cv)
if category:
rows = db.query("entries", filters={"category": category}, order="-created_at", limit=200)
else:
rows = db.query("entries", order="-created_at", limit=200)
parts = []
for row in rows:
parts.append(_entry_json(row))
return {"status": 200, "body": '{"data":[' + ",".join(parts) + "]}"}
def _create_entry(req):
body = _parse_body(str(req.get("body", "")))
if not body:
return {"status": 400, "body": '{"error":"JSON body required"}'}
message = body.get("message", "")
if not message:
return {"status": 400, "body": '{"error":"message is required"}'}
category = body.get("category", "note")
username = body.get("username", "")
created_at = body.get("created_at", "")
user_id = str(req.get("user_id", ""))
# Validate category against admin settings
allowed = _allowed_categories()
if allowed and category not in allowed:
return {"status": 400, "body": '{"error":"invalid category: ' + _esc(category) + '"}'}
row_id = db.insert("entries", {
"user_id": user_id,
"username": username,
"category": category,
"message": message,
"created_at": created_at,
})
_prune()
return {"status": 201, "body": _entry_json({
"id": str(row_id), "user_id": user_id, "username": username,
"category": category, "message": message, "created_at": created_at,
})}
def _delete_entry(req, entry_id):
if not entry_id:
return {"status": 400, "body": '{"error":"entry id required"}'}
db.delete("entries", entry_id)
return {"status": 200, "body": '{"deleted":true,"id":"' + _esc(entry_id) + '"}'}
# ── Settings helpers ─────────────────────────
def _allowed_categories():
raw = settings.get("categories")
if not raw:
return []
return [p.strip() for p in raw.split(",") if p.strip()]
def _prune():
max_raw = settings.get("max_entries")
if not max_raw:
return
limit = int(max_raw)
if limit <= 0:
return
rows = db.query("entries", order="-created_at", limit=limit + 50)
if len(rows) > limit:
for row in rows[limit:]:
db.delete("entries", row["id"])
# ── JSON helpers (no json module in sandbox) ─
def _esc(s):
return str(s).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _entry_json(e):
return (
'{"id":"' + _esc(e.get("id", ""))
+ '","user_id":"' + _esc(e.get("user_id", ""))
+ '","username":"' + _esc(e.get("username", ""))
+ '","category":"' + _esc(e.get("category", ""))
+ '","message":"' + _esc(e.get("message", ""))
+ '","created_at":"' + _esc(e.get("created_at", ""))
+ '"}'
)
def _parse_body(raw):
"""Minimal flat-object JSON parser. Handles {"key":"value"} payloads."""
s = raw.strip()
if not s or s[0] != "{":
return None
s = s[1:]
if s and s[-1] == "}":
s = s[:-1]
result = {}
in_str = False
escape = False
current = ""
pairs = []
for ch in s.elems():
if escape:
current += ch
escape = False
elif ch == "\\":
current += ch
escape = True
elif ch == '"':
current += ch
in_str = not in_str
elif ch == "," and not in_str:
pairs.append(current.strip())
current = ""
else:
current += ch
if current.strip():
pairs.append(current.strip())
for pair in pairs:
colon = -1
in_s = False
esc = False
for i in range(len(pair)):
c = pair[i]
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_s = not in_s
elif c == ":" and not in_s:
colon = i
break
if colon < 0:
continue
key = _unquote(pair[:colon].strip())
val = _unquote(pair[colon+1:].strip())
if key:
result[key] = val
return result
def _unquote(s):
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
return s[1:-1].replace('\\"', '"').replace("\\\\", "\\").replace("\\n", "\n")
return s

View File

@@ -241,6 +241,33 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
return
}
// v0.37.15: Read script.star from archive if present.
// Keeps the manifest clean — authors write a file, installer injects
// it as _starlark_script for the sandbox runner.
if _, hasInline := manifest["_starlark_script"]; !hasInline {
for _, f := range zr.File {
name := f.Name
base := filepath.Base(name)
if base == "script.star" && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
break
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
break
}
script := string(data)
if strings.TrimSpace(script) != "" {
manifest["_starlark_script"] = script
log.Printf("[packages] Injected script.star (%d bytes) into manifest", len(data))
}
break
}
}
}
// Validate required fields
pkgID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)

View File

@@ -123,6 +123,169 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
}
// ── Lifecycle operations (v0.37.15) ──
// Unclaim returns a claimed assignment to unassigned.
// POST /api/v1/workflow-assignments/:id/unclaim
func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) {
assignmentID := c.Param("id")
userID := c.GetString("user_id")
role, _ := c.Get("role")
// Auth: claimer or team admin or global admin
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
if err != nil || a == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) {
isTA := false
if h.stores.Teams != nil {
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
}
if !isTA {
c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"})
return
}
}
n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"})
return
}
if n == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
return
}
// Emit WS event
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
if h.hub != nil && channelID != "" {
payload, _ := json.Marshal(map[string]any{
"assignment_id": assignmentID,
"unclaimed_by": userID,
"channel_id": channelID,
})
evt := events.Event{
Label: "workflow.unclaimed",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
for _, uid := range pids {
h.hub.PublishToUser(uid, evt)
}
}
c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID})
}
// Reassign changes the assigned_to on a claimed assignment.
// POST /api/v1/workflow-assignments/:id/reassign
func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) {
assignmentID := c.Param("id")
userID := c.GetString("user_id")
role, _ := c.Get("role")
var body struct {
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
// Auth: team admin or global admin
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
if err != nil || a == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
if role != "admin" {
isTA := false
if h.stores.Teams != nil {
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
}
if !isTA {
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
return
}
}
n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"})
return
}
if n == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
return
}
// Emit WS event
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
if h.hub != nil && channelID != "" {
payload, _ := json.Marshal(map[string]any{
"assignment_id": assignmentID,
"reassigned_by": userID,
"new_assignee": body.UserID,
"channel_id": channelID,
})
evt := events.Event{
Label: "workflow.reassigned",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
for _, uid := range pids {
h.hub.PublishToUser(uid, evt)
}
}
c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID})
}
// CancelAssignment cancels a single assignment.
// POST /api/v1/workflow-assignments/:id/cancel
func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) {
assignmentID := c.Param("id")
userID := c.GetString("user_id")
role, _ := c.Get("role")
// Auth: team admin or global admin
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
if err != nil || a == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
if role != "admin" {
isTA := false
if h.stores.Teams != nil {
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
}
if !isTA {
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
return
}
}
n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"})
return
}
if n == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"})
return
}
c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID})
}
// CommentOnAssignment adds a review comment to an assignment.
// POST /api/v1/workflow-assignments/:id/comment
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {

View File

@@ -302,6 +302,128 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
})
}
// ── Cancel (v0.37.15) ────────────────────────
// CancelInstance cancels a workflow instance and all its open assignments.
// POST /api/v1/channels/:id/workflow/cancel
func (h *WorkflowInstanceHandler) CancelInstance(c *gin.Context) {
ctx := c.Request.Context()
channelID := c.Param("id")
userID := c.GetString("user_id")
role, _ := c.Get("role")
workflowID, _, status, err := h.readWorkflowState(ctx, channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
return
}
if status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
return
}
// Auth: instance owner, team admin, or global admin
if role != "admin" {
ch, err := h.stores.Channels.GetByID(ctx, channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
isOwner := ch.UserID == userID
isTeamAdmin := false
if ch.TeamID != nil && h.stores.Teams != nil {
isTeamAdmin, _ = h.stores.Teams.IsTeamAdmin(ctx, *ch.TeamID, userID)
}
if !isOwner && !isTeamAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "only instance owner, team admin, or global admin can cancel"})
return
}
}
// Cancel the workflow instance
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
return
}
// Cancel all open assignments
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
// Emit WS event
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
"channel_id": channelID,
"workflow_id": workflowID,
"cancelled_by": userID,
"assignments_cancelled": cancelled,
})
c.JSON(http.StatusOK, gin.H{
"cancelled": true,
"channel_id": channelID,
"assignments_cancelled": cancelled,
})
}
// CancelTeamInstance cancels a workflow instance via the team-admin monitor.
// POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
teamID := c.Param("teamId")
channelID := c.Param("channelId")
userID := c.GetString("user_id")
role, _ := c.Get("role")
ctx := c.Request.Context()
// Auth: team admin or global admin
if role != "admin" {
if h.stores.Teams == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
return
}
isTA, _ := h.stores.Teams.IsTeamAdmin(ctx, teamID, userID)
if !isTA {
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
return
}
}
// Verify the channel belongs to this team
ch, err := h.stores.Channels.GetByID(ctx, channelID)
if err != nil || ch.TeamID == nil || *ch.TeamID != teamID {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow instance not found for this team"})
return
}
_, _, status, err := h.readWorkflowState(ctx, channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
return
}
if status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
return
}
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
return
}
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
"channel_id": channelID,
"cancelled_by": userID,
"assignments_cancelled": cancelled,
})
c.JSON(http.StatusOK, gin.H{
"cancelled": true,
"channel_id": channelID,
"assignments_cancelled": cancelled,
})
}
// ── Helpers ─────────────────────────────────
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {

View File

@@ -712,6 +712,7 @@ func main() {
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15
// Workflow assignments (v0.26.4 — team assignment queue)
wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
@@ -720,6 +721,9 @@ func main() {
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15
protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15
protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15
// Tasks (v0.27.1, permissions v0.27.2)
taskH := handlers.NewTaskHandler(stores)
@@ -1086,6 +1090,10 @@ func main() {
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
// Team workflow instance cancel (v0.37.15)
teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance)
// Team tasks — admin CRUD (v0.27.5)
teamTaskH := handlers.NewTaskHandler(stores)
teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)

View File

@@ -5,7 +5,7 @@
*/}}
{{define "surface-team-admin"}}
<div id="team-admin-mount" class="surface-team-admin" style="flex-direction:column;">
<div id="team-admin-mount" class="surface-team-admin" style="height:100%;overflow:hidden;">
<div class="settings-placeholder" style="padding:40px;text-align:center;">Loading team admin&hellip;</div>
</div>
{{end}}

View File

@@ -510,6 +510,9 @@ type ChannelStore interface {
// CompleteWorkflow sets workflow_status='completed' and ai_mode='off'.
CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error
// CancelWorkflow sets workflow_status='cancelled' and ai_mode='off'. (v0.37.15)
CancelWorkflow(ctx context.Context, channelID string) error
// RejectWorkflowToStage resets current_stage (no stage_data change).
RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error

View File

@@ -644,6 +644,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
return err
}
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = $1
WHERE id = $2 AND type = 'workflow'
`, time.Now().UTC(), channelID)
return err
}
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `

View File

@@ -449,6 +449,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
return channelID, err
}
// ── Lifecycle operations (v0.37.15) ──
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'cancelled'
WHERE channel_id = $1 AND status IN ('unassigned', 'claimed')
`, channelID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
WHERE id = $1 AND status = 'claimed'
`, assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET assigned_to = $1, claimed_at = $2
WHERE id = $3 AND status = 'claimed'
`, newUserID, time.Now().UTC(), assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'cancelled'
WHERE id = $1 AND status IN ('unassigned', 'claimed')
`, assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
// Find least-recently-assigned team member
rows, err := DB.QueryContext(ctx, `

View File

@@ -645,6 +645,15 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
return err
}
func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = ?
WHERE id = ? AND type = 'workflow'
`, time.Now().UTC().Format(timeFmt), channelID)
return err
}
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, `

View File

@@ -452,6 +452,56 @@ func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID
return channelID, err
}
// ── Lifecycle operations (v0.37.15) ──
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'cancelled'
WHERE channel_id = ? AND status IN ('unassigned', 'claimed')
`, channelID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
WHERE id = ? AND status = 'claimed'
`, assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET assigned_to = ?, claimed_at = ?
WHERE id = ? AND status = 'claimed'
`, newUserID, time.Now().UTC().Format(timeFmt), assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'cancelled'
WHERE id = ? AND status IN ('unassigned', 'claimed')
`, assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim

View File

@@ -55,6 +55,23 @@ type WorkflowStore interface {
// Returns the assigned user ID, or "" if no members available.
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
// ── Lifecycle operations (v0.37.15) ──
// CancelAssignmentsForChannel sets status='cancelled' on all
// unassigned/claimed assignments for a channel.
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
// UnclaimAssignment returns a claimed assignment to unassigned.
// Returns rows affected (0 = not claimed or not found).
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
// ReassignAssignment changes assigned_to on a claimed assignment.
// Returns rows affected.
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
// CancelAssignment sets a single assignment to cancelled.
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
// ── Review Comments (v0.35.0) ──
// GetAssignmentByID returns a single assignment by ID.

View File

@@ -248,6 +248,16 @@ export function createDomains(restClient) {
updateWorkflow: (id, wfId, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}`, data),
deleteWorkflow: (id, wfId) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}`),
publishWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/publish`, {}),
// Team workflow stages (v0.37.15 — FE wiring for existing BE routes)
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
// Team assignments + monitor (v0.37.15)
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
// Team tasks
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
@@ -265,6 +275,19 @@ export function createDomains(restClient) {
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}),
},
// ── 15b. Workflow Assignments (v0.37.15) ─
workflowAssignments: {
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
},
// ── 16. Tasks ──────────────────────────

View File

@@ -45,7 +45,7 @@ const NAV_ITEMS = [
{ key: 'personas', label: 'Personas', gate: 'personas' },
{ key: 'profile', label: 'Profile' },
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'workflows', label: 'Assignments' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'knowledge', label: 'Knowledge Bases' },
@@ -64,7 +64,7 @@ const BYOK_ITEMS = [
const SECTION_TITLES = {
general: 'General', appearance: 'Appearance', models: 'Models',
personas: 'Personas', profile: 'Profile', teams: 'Teams',
workflows: 'Workflows', tasks: 'Tasks', gitkeys: 'Git Keys',
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
notifications: 'Notifications',

View File

@@ -1,88 +1,138 @@
/**
* Settings > Workflows — read-only view of user's visible workflows
* Settings > Workflows — v0.37.15 rewrite
*
* Replaces read-only workflow list with MyAssignments (E4).
* Shows the user's personal assignment queue across all teams.
*/
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`;
}
export default function WorkflowsSection() {
const [workflows, setWorkflows] = useState([]);
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [stages, setStages] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(data || []);
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(wf) {
setDetail(wf);
const userId = sw.auth?.user?.id;
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
const available = assignments.filter(a => a.status === 'unassigned');
async function claim(id) {
try {
const d = await sw.api.workflows.get(wf.id);
setStages(d.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
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>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
${detail.is_active ? 'active' : 'inactive'}
</span>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
`}
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
<span>Slug: <strong>/${detail.slug}</strong></span>
<span>Entry: <strong>${detail.entry_mode}</strong></span>
<span>Version: <strong>${detail.version || 0}</strong></span>
</div>
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id}>
<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}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
</div>
`)}
</div>
</div>
`;
return html`
<div>
${mine.length > 0 && html`
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
${w.is_active ? 'active' : 'inactive'}
</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
${mine.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${available.length > 0 && html`
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
<div class="admin-list">
${available.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${mine.length === 0 && available.length === 0 && html`
<div class="empty-hint">No assignments</div>
`}
</div>
`;
}
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const userId = sw.auth?.user?.id;
const isMine = a.assigned_to === userId;
async function claim() {
try {
await sw.api.workflowAssignments.claim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim() {
try {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete() {
try {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<div style="display:flex;gap:4px;">
${a.status === 'unassigned' && html`
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
`}
${isMine && html`
<button class="btn-small" onClick=${unclaim}>Release</button>
<button class="btn-small btn-primary"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
`}
</div>
</div>
`;
}

View File

@@ -110,7 +110,7 @@ function TeamAdminSurface() {
// Team picker if multiple teams
if (!selectedTeam && adminTeams.length > 1) {
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -144,7 +144,7 @@ function TeamAdminSurface() {
// No teams
if (!selectedTeam && adminTeams.length === 0) {
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -167,7 +167,7 @@ function TeamAdminSurface() {
}
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
${/* Top Bar */``}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
@@ -188,7 +188,7 @@ function TeamAdminSurface() {
</span>
</div>
<div class="settings-body">
<div style="display:flex;flex:1;min-height:0;">
${/* Left Nav */``}
<div class="settings-nav">
${SECTIONS.map(s => html`
@@ -202,10 +202,7 @@ function TeamAdminSurface() {
${/* Content */``}
<div class="settings-content">
<div class="settings-content-header">
<h2>${sectionLabel}</h2>
</div>
<div class="settings-content-body">
${SectionComponent && teamId
? html`<${SectionComponent} teamId=${teamId} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
@@ -213,7 +210,6 @@ function TeamAdminSurface() {
</div>
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;

View File

@@ -1,16 +1,67 @@
/**
* Team Admin > Workflows
* Team Admin > Workflows — v0.37.15 rewrite
*
* Tab layout: Workflows | Assignments | Monitor
* - Workflows: CRUD + inline stage editor (E2)
* - Assignments: Team assignment queue (E1)
* - Monitor: Active instance list with cancel (E3)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
const HISTORY_MODES = ['full', 'summary', 'fresh'];
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`;
}
// ── Main Section ────────────────────────────
export default function WorkflowsSection({ teamId }) {
const [tab, setTab] = useState('Workflows');
return html`
<div>
<div class="admin-tabs" style="display:flex;gap:4px;margin-bottom:16px;">
${TABS.map(t => html`
<button key=${t}
class="btn-small ${tab === t ? 'btn-primary' : ''}"
onClick=${() => setTab(t)}>${t}</button>
`)}
</div>
${tab === 'Workflows' && html`<${WorkflowsTab} teamId=${teamId} />`}
${tab === 'Assignments' && html`<${AssignmentsTab} teamId=${teamId} />`}
${tab === 'Monitor' && html`<${MonitorTab} teamId=${teamId} />`}
</div>
`;
}
// ── Tab 1: Workflows ────────────────────────
function WorkflowsTab({ teamId }) {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | workflow
const [stages, setStages] = useState([]);
const [editing, setEditing] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
@@ -23,15 +74,6 @@ export default function WorkflowsSection({ teamId }) {
useEffect(() => { load(); }, [load]);
async function openEdit(wf) {
setEditing(wf);
try {
const detail = await sw.api.teams.workflows(teamId);
const match = (detail || []).find(w => w.id === wf.id);
setStages(match?.stages || wf.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
async function createWorkflow(e) {
e.preventDefault();
const form = e.target;
@@ -48,83 +90,10 @@ export default function WorkflowsSection({ teamId }) {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.updateWorkflow(teamId, editing.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
sw.toast('Workflow updated', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow(wfId) {
try {
await sw.api.teams.publishWorkflow(teamId, wfId);
sw.toast('Workflow published', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow(id) {
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, id);
sw.toast('Workflow deleted', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${editing.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${editing.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=${editing.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
<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>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
`;
if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing}
onBack=${() => { setEditing(null); load(); }} />`;
return html`
<div>
@@ -155,7 +124,7 @@ export default function WorkflowsSection({ teamId }) {
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => setEditing(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
@@ -169,3 +138,372 @@ export default function WorkflowsSection({ teamId }) {
</div>
`;
}
// ── Workflow Editor (with Stage Editor) ─────
function WorkflowEditor({ teamId, workflow, onBack }) {
const [stages, setStages] = useState([]);
const [personas, setPersonas] = useState([]);
const [teams, setTeams] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const loadStages = useCallback(async () => {
try {
const [s, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflow.id),
sw.api.teams.personas(teamId),
]);
setStages(s || []);
setPersonas(p || []);
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 {
await sw.api.teams.updateWorkflow(teamId, workflow.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
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="btn-small" onClick=${onBack}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${publishWorkflow}>Publish</button>
<button type="button" class="btn-small btn-danger" 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-group"><label>Description</label><input name="description" value=${workflow.description || ''} /></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>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">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="btn-small" 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>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
<button class="btn-small btn-danger" 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)}
personas=${personas}
teams=${teams}
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
onCancel=${() => setEditingStage(null)}
/>
`}
</div>
`;
}
// ── Stage Form ──────────────────────────────
function StageForm({ stage, personas, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
function submit() {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
}
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.replace('_', ' ')}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Persona (LLM)</label>
<select value=${personaId} onChange=${e => setPersonaId(e.target.value)}>
<option value="">\u2014 none \u2014</option>
${personas.map(p => html`<option key=${p.id} value=${p.id}>${p.name}</option>`)}
</select>
</div>
<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>
<div class="form-row">
<div class="form-group">
<label>History Mode</label>
<select value=${historyMode} onChange=${e => setHistoryMode(e.target.value)}>
${HISTORY_MODES.map(m => html`<option key=${m} value=${m}>${m}</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>
<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="btn-small btn-primary" onClick=${submit}>
${stage ? 'Update' : 'Add Stage'}
</button>
<button type="button" class="btn-small" 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="btn-small" onClick=${() => unclaim(a.id)}>Release</button>
<button class="btn-small btn-primary"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="btn-small btn-success" 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="btn-small btn-primary" 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 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="btn-small"
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
<button class="btn-small btn-danger" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
</div>
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
</div>
</div>
`;
}