From d005e8a30f203bedaadbc4c5c6decc93bfc9c91a Mon Sep 17 00:00:00 2001 From: xcaliber Date: Mon, 23 Mar 2026 19:58:17 +0000 Subject: [PATCH] Changeset 0.37.15 (#227) --- CHANGELOG.md | 48 ++ VERSION | 2 +- packages/build.sh | 10 +- packages/team-activity-log/css/main.css | 224 ++++++++ packages/team-activity-log/js/main.js | 221 ++++++++ packages/team-activity-log/manifest.json | 48 ++ packages/team-activity-log/script.star | 186 +++++++ server/handlers/packages.go | 27 + server/handlers/workflow_assignments.go | 163 ++++++ server/handlers/workflow_instances.go | 122 +++++ server/main.go | 8 + .../pages/templates/surfaces/team-admin.html | 2 +- server/store/interfaces.go | 3 + server/store/postgres/channel.go | 9 + server/store/postgres/workflows.go | 50 ++ server/store/sqlite/channel.go | 9 + server/store/sqlite/workflows.go | 50 ++ server/store/workflow_iface.go | 17 + src/js/sw/sdk/api-domains.js | 23 + src/js/sw/surfaces/settings/index.js | 4 +- src/js/sw/surfaces/settings/workflows.js | 172 +++--- src/js/sw/surfaces/team-admin/index.js | 22 +- src/js/sw/surfaces/team-admin/workflows.js | 514 +++++++++++++++--- 23 files changed, 1764 insertions(+), 170 deletions(-) create mode 100644 packages/team-activity-log/css/main.css create mode 100644 packages/team-activity-log/js/main.js create mode 100644 packages/team-activity-log/manifest.json create mode 100644 packages/team-activity-log/script.star diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b86abc..fb88964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## [0.37.15.0] — 2026-03-23 + +### Summary + +Workflow Ownership & Lifecycle (CS1–CS5). 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 diff --git a/VERSION b/VERSION index 1ab5ac2..fb9f0e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.14.23 +0.37.15.0 diff --git a/packages/build.sh b/packages/build.sh index 1087006..603d804 100644 --- a/packages/build.sh +++ b/packages/build.sh @@ -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/" + [ -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) diff --git a/packages/team-activity-log/css/main.css b/packages/team-activity-log/css/main.css new file mode 100644 index 0000000..0187b25 --- /dev/null +++ b/packages/team-activity-log/css/main.css @@ -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; +} diff --git a/packages/team-activity-log/js/main.js b/packages/team-activity-log/js/main.js new file mode 100644 index 0000000..67b2e33 --- /dev/null +++ b/packages/team-activity-log/js/main.js @@ -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 = '

SDK boot failed: ' + e.message + '

'; + 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` +
+
+
+

Activity Log

+ Team activity tracker +
+
+
+
+
+ + <${EntryForm} onSubmit=${addEntry} /> + +
+ + ${CATEGORIES.map(c => html` + + `)} + ${entries.length} entries +
+ + ${loading + ? html`
Loading…
` + : html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />` + } +
+ `; + } + + 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` +
+ + setMessage(e.target.value)} + disabled=${submitting} /> + +
+ `; + } + + function EntryList({ entries, onDelete }) { + if (!entries.length) { + return html`
No entries yet. Log your first activity above.
`; + } + + return html` +
+ ${entries.map(e => html` +
+
+ + ${esc(e.username || 'unknown')} + ${timeAgo(e.created_at)} + +
+
${esc(e.message)}
+
+ `)} +
+ `; + } + + // ── 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'); +})(); diff --git a/packages/team-activity-log/manifest.json b/packages/team-activity-log/manifest.json new file mode 100644 index 0000000..ea64f0d --- /dev/null +++ b/packages/team-activity-log/manifest.json @@ -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" + } + } +} diff --git a/packages/team-activity-log/script.star b/packages/team-activity-log/script.star new file mode 100644 index 0000000..1e4026f --- /dev/null +++ b/packages/team-activity-log/script.star @@ -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 diff --git a/server/handlers/packages.go b/server/handlers/packages.go index e4cd5d8..d667bc7 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -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) diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go index 47e0e8a..11e6bba 100644 --- a/server/handlers/workflow_assignments.go +++ b/server/handlers/workflow_assignments.go @@ -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) { diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index 4d12634..867a0d9 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -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) { diff --git a/server/main.go b/server/main.go index d63e1e1..1371b2a 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/pages/templates/surfaces/team-admin.html b/server/pages/templates/surfaces/team-admin.html index 748eefd..03ba960 100644 --- a/server/pages/templates/surfaces/team-admin.html +++ b/server/pages/templates/surfaces/team-admin.html @@ -5,7 +5,7 @@ */}} {{define "surface-team-admin"}} -
+
Loading team admin…
{{end}} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 1b2fa1f..0116af0 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -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 diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go index 139c72f..f69d333 100644 --- a/server/store/postgres/channel.go +++ b/server/store/postgres/channel.go @@ -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, ` diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 414f0bc..e9cb478 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -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, ` diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go index e22b6c6..331fbaf 100644 --- a/server/store/sqlite/channel.go +++ b/server/store/sqlite/channel.go @@ -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, ` diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index ba5cca3..635383b 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -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 diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go index 9b06d12..498860a 100644 --- a/server/store/workflow_iface.go +++ b/server/store/workflow_iface.go @@ -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. diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 5c33e0d..01b0112 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -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 ────────────────────────── diff --git a/src/js/sw/surfaces/settings/index.js b/src/js/sw/surfaces/settings/index.js index 10496c8..915f0b2 100644 --- a/src/js/sw/surfaces/settings/index.js +++ b/src/js/sw/surfaces/settings/index.js @@ -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', diff --git a/src/js/sw/surfaces/settings/workflows.js b/src/js/sw/surfaces/settings/workflows.js index 2f1a3b6..aca71e6 100644 --- a/src/js/sw/surfaces/settings/workflows.js +++ b/src/js/sw/surfaces/settings/workflows.js @@ -1,87 +1,137 @@ /** - * 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`
Loading\u2026
`; - if (detail) return html` -
-
- -

${detail.name}

- - ${detail.is_active ? 'active' : 'inactive'} - -
- - ${detail.description && html` -

${detail.description}

- `} - -
- Slug: /${detail.slug} - Entry: ${detail.entry_mode} - Version: ${detail.version || 0} -
- -
Stages (${stages.length})
-
- ${stages.length === 0 && html`
No stages defined
`} - ${stages.map((s, i) => html` -
- #${i + 1} - ${s.name || `Stage ${i + 1}`} - ${s.stage_mode} - ${s.persona_id && html`persona`} -
- `)} -
-
- `; - return html`
-
- ${workflows.length === 0 && html`
No workflows available.
`} - ${workflows.map(w => html` -
openDetail(w)}> -
- ${w.name} - /${w.slug} -
- ${w.entry_mode} - - ${w.is_active ? 'active' : 'inactive'} - - v${w.version || 0} -
- `)} + ${mine.length > 0 && html` +

Claimed (${mine.length})

+
+ ${mine.map(a => html` + <${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam /> + `)} +
+ `} + + ${available.length > 0 && html` +

Available (${available.length})

+
+ ${available.map(a => html` + <${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam /> + `)} +
+ `} + + ${mine.length === 0 && available.length === 0 && html` +
No assignments
+ `} +
+ `; +} + +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` +
+
+ ${a.workflow_name || 'Workflow'} + ${showTeam && a.team_name && html`${a.team_name}`} + ${a.stage_name || `Stage ${a.stage}`} + ${a.sla_breached && html`SLA`} + ${_timeAgo(a.created_at)} +
+
+ ${a.status === 'unassigned' && html` + + `} + ${isMine && html` + + + + `}
`; diff --git a/src/js/sw/surfaces/team-admin/index.js b/src/js/sw/surfaces/team-admin/index.js index db40287..08e793f 100644 --- a/src/js/sw/surfaces/team-admin/index.js +++ b/src/js/sw/surfaces/team-admin/index.js @@ -110,7 +110,7 @@ function TeamAdminSurface() { // Team picker if multiple teams if (!selectedTeam && adminTeams.length > 1) { return html` -
+
+
+
${/* Top Bar */``} -
+
${/* Left Nav */``}
${SECTIONS.map(s => html` @@ -202,15 +202,11 @@ function TeamAdminSurface() { ${/* Content */``}
-
-

${sectionLabel}

-
-
- ${SectionComponent && teamId - ? html`<${SectionComponent} teamId=${teamId} />` - : html`
Loading\u2026
` - } -
+

${sectionLabel}

+ ${SectionComponent && teamId + ? html`<${SectionComponent} teamId=${teamId} />` + : html`
Loading\u2026
` + }
diff --git a/src/js/sw/surfaces/team-admin/workflows.js b/src/js/sw/surfaces/team-admin/workflows.js index a223685..c3b4c37 100644 --- a/src/js/sw/surfaces/team-admin/workflows.js +++ b/src/js/sw/surfaces/team-admin/workflows.js @@ -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` +
+
+ ${TABS.map(t => html` + + `)} +
+ ${tab === 'Workflows' && html`<${WorkflowsTab} teamId=${teamId} />`} + ${tab === 'Assignments' && html`<${AssignmentsTab} teamId=${teamId} />`} + ${tab === 'Monitor' && html`<${MonitorTab} teamId=${teamId} />`} +
+ `; +} + +// ── 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`
Loading\u2026
`; - if (editing) return html` -
-
- -

Edit: ${editing.name}

-
- - -
-
-
-
- -
-
-
- - -
Stages (${stages.length})
-
- ${stages.map((s, i) => html` -
- #${i + 1} - ${s.name || `Stage ${i + 1}`} - ${s.stage_mode || '\u2014'} -
- `)} - ${stages.length === 0 && html`
No stages defined
`} -
- -
- -
-
- `; + if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing} + onBack=${() => { setEditing(null); load(); }} />`; return html`
@@ -155,7 +124,7 @@ export default function WorkflowsSection({ teamId }) {
${workflows.length === 0 && html`
No workflows
`} ${workflows.map(w => html` -
openEdit(w)}> +
setEditing(w)}>
${w.name} /${w.slug} @@ -169,3 +138,372 @@ export default function WorkflowsSection({ teamId }) {
`; } + +// ── 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` +
+
+ +

Edit: ${workflow.name}

+
+ + +
+
+
+
+ +
+
+
+ + +
+ +
+
+ + +
+
+
Stages (${stages.length})
+
+ +
+ +
+ ${stages.map((s, i) => html` +
+ #${i + 1} + ${s.name || `Stage ${i + 1}`} + ${s.stage_mode || '\u2014'} + ${s.persona_id && html`persona`} + ${s.assignment_team_id && html`team assign`} + + +
+ `)} + ${stages.length === 0 && html`
No stages defined
`} +
+ + ${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)} + /> + `} +
+ `; +} + +// ── 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` +
+
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setSla(e.target.value)} placeholder="e.g. 3600" /> +
+
+ +
+ + +
+
+ `; +} + +// ── 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`
Loading\u2026
`; + + const userId = sw.auth?.user?.id; + + return html` +
+

My Active (${claimed.length})

+
+ ${claimed.map(a => html` +
+
+ ${a.workflow_name || 'Workflow'} + ${a.stage_name || `Stage ${a.stage}`} + ${a.sla_breached && html`SLA`} +
+
+ + + +
+
+ `)} + ${claimed.length === 0 && html`
No claimed assignments
`} +
+ +

Available (${unassigned.length})

+
+ ${unassigned.map(a => html` +
+
+ ${a.workflow_name || 'Workflow'} + ${a.stage_name || `Stage ${a.stage}`} + ${_timeAgo(a.created_at)} +
+ +
+ `)} + ${unassigned.length === 0 && html`
No available assignments
`} +
+
+ `; +} + +// ── 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`
Loading\u2026
`; + + return html` +
+

Active Instances (${instances.length})

+
+ ${instances.map(inst => html` +
+
+ ${inst.workflow_name} + ${inst.stage_name || `Stage ${inst.current_stage}`} + ${inst.sla_breached && html`SLA breached`} + Age: ${_formatDuration(inst.age_seconds)} +
+
+ + +
+
+ `)} + ${instances.length === 0 && html`
No active instances
`} +
+
+ `; +}