Changeset 0.35.0 (#209)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 09:59:53 +00:00
committed by xcaliber
parent d16bb93177
commit bf8082e69f
37 changed files with 2324 additions and 129 deletions

View File

@@ -1,5 +1,61 @@
# Changelog
## [0.35.0] — 2026-03-19
### Summary
Workflow Product: transforms the workflow engine from a linear
stage-runner into a product-grade automation tool. Conditional routing,
progressive forms, Starlark data pipeline, structured review with
comments, and a monitoring dashboard with SLA tracking.
### New
- **Conditional routing engine** — `transition_rules.conditions[]`
evaluated against accumulated `stage_data`. Supports eq/neq/gt/lt/
gte/lte/in/contains/exists operators. First match wins; no match
falls back to ordinal+1. Backward compatible.
- **`workflow_route` tool** — persona-callable tool for AI-triggered
routing to named stages. Supports forward and backward jumps (loop
stages). Records routing decisions in `stage_data._route_history_latest`.
- **Starlark `workflow.route()`** — extension-driven routing for
confidence-based escalation patterns.
- **`on_advance` hook** — Starlark entry point fires synchronously
after stage transition. Can enrich/transform `stage_data` or reject
the transition. Configured via `transition_rules.on_advance`.
- **Progressive forms** — `form_template.fieldsets[]` for multi-step
forms with next/back navigation and step indicators.
- **Conditional fields** — `FormField.condition` (when/op/value)
controls field visibility. Server-side validation skips hidden fields.
- **Workflow branding** — `accent_color`, `logo_url`, `tagline` now
applied to workflow visitor pages via CSS custom properties.
- **Structured review** — side-by-side layout with data card + actions
panel. Keyboard shortcuts: Ctrl+Enter approve, Ctrl+Shift+Enter reject.
- **Review comments** — `POST /workflow-assignments/:id/comment` appends
reviewer notes to the assignment's `review_comments` JSONB array.
- **Monitoring dashboard** — admin endpoints for active instances,
stage funnels, and stale detection. SLA computed from
`stage_entered_at + sla_seconds` vs NOW().
- **SLA tracking** — `sla_seconds` column on workflow_stages,
`stage_entered_at` on channels. Dashboard shows remaining time
with green/yellow/red indicators.
### Schema
- `workflow_stages.sla_seconds INTEGER` (018 modified in-place)
- `channels.stage_entered_at TIMESTAMPTZ/TEXT` (005 modified in-place)
- `workflow_assignments.review_comments JSONB/TEXT DEFAULT '[]'` (018)
- Index `idx_channels_workflow_active` (005)
- DB rebuild required (wipe data directory)
### New files
- `server/workflow/routing.go` — conditional routing engine
- `server/handlers/workflow_hooks.go` — on_advance hook firing
- `server/handlers/workflow_monitor.go` — monitoring dashboard
- `src/js/workflow-monitor.js` — admin monitoring frontend
- `packages/icd-test-runner/js/crud/workflow-product.js` — E2E tests
## [0.33.0] — 2026-03-19
### Summary

View File

@@ -1 +1 @@
0.34.0
0.35.0

148
docs/DESIGN-0.35.0.md Normal file
View File

@@ -0,0 +1,148 @@
# DESIGN-0.35.0 — Workflow Product
Transforms the workflow engine from a linear stage-runner into a
product-grade automation tool. Seven changesets covering conditional
routing, AI-triggered routing, data pipeline hooks, progressive forms,
conditional fields, structured review, and a monitoring dashboard.
Depends on: v0.34.0 (Data Portability), v0.31.2 (Team Workflows),
v0.29.0 (Starlark Sandbox).
## Schema Changes
Migrations modified in-place (DB rebuild required):
**005_channels.sql** — add `stage_entered_at` (PG: `TIMESTAMPTZ`,
SQLite: `TEXT`). Tracks when the current stage was entered for SLA
computation. Also add index `idx_channels_workflow_active` on
`(workflow_id, workflow_status)` for monitoring queries.
**018_workflows.sql** — add `sla_seconds INTEGER` to `workflow_stages`.
Add `review_comments` (PG: `JSONB DEFAULT '[]'`, SQLite: `TEXT
DEFAULT '[]'`) to `workflow_assignments`.
No new migration files. No new tables.
## Conditional Routing Engine
**File:** `server/workflow/routing.go`
The existing advance logic hardcoded `nextStage = currentStage + 1`.
The routing engine replaces this with condition evaluation:
1. Parse `transition_rules.conditions[]` from the departing stage
2. Evaluate each condition against accumulated `stage_data`
3. First match → resolve `target_stage` (by name or ordinal)
4. No match → fallback to `ordinal + 1`
**Expression format:** `{field, op, value, target_stage}`. Operators:
`eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `contains`, `exists`,
`not_exists`. Loose equality via `fmt.Sprintf("%v")` comparison.
Numeric comparison extracts `float64` from both sides.
**Wiring:** `ResolveNextStage()` called in all 4 advance sites:
handler (`workflow_instances.go`), tool (`tools/workflow.go`), forms
auto-advance (`workflow_forms.go`), Starlark module
(`sandbox/workflow_module.go`).
**Backward compatible:** no conditions = `ordinal + 1`.
## AI-Triggered Routing
**Tool:** `workflow_route` — persona calls with `{target_stage, reason}`.
Uses `ResolveStageByName()` (case-insensitive). Records decision in
`stage_data._route_history_latest`. Allows forward and backward jumps
(loop stages for iterative correction).
**Starlark:** `workflow.route(channel_id, target_stage, reason)` for
extension-driven escalation patterns. Requires `workflow.access`.
## on_advance Hook
**File:** `server/handlers/workflow_hooks.go`
Fires synchronously after `AdvanceWorkflowStage` succeeds. Configured
per-stage in `transition_rules.on_advance: {package_id, entry_point}`.
Hook receives: `{stage_data, previous_stage, current_stage, channel_id}`.
Returns: `{stage_data: {...}}` (enriched), `None` (no change), or
`{error: "msg"}` (logged, not rolled back — future: rollback support).
The existing `http` Starlark module (v0.29.1) is available in hooks,
enabling external API enrichment without new infrastructure.
## Progressive Forms
**Model:** `TypedFormTemplate.Fieldsets []FormFieldset` — optional
array of `{label, fields[]}`. When present, top-level `fields` is
replaced by flattened fieldset fields in `ParseTypedFormTemplate()`.
**Frontend:** `workflow.html` renders one fieldset at a time with
step indicator, next/back navigation. All fields submitted together
on the final step.
## Conditional Fields
**Model:** `FormField.Condition *FieldCondition``{when, op, value}`.
Operators: `eq` (default), `neq`, `in`, `exists`.
**Server-side:** `ValidateFormData()` calls `evaluateFieldCondition()`
before each field. Hidden fields are skipped (no required check, no
type validation).
**Client-side:** `workflow.html` attaches `change`/`input` listeners
to source fields. Target fields toggle `cond-hidden` CSS class.
## Structured Review
**Review comments:** `review_comments JSONB` on `workflow_assignments`.
`POST /workflow-assignments/:id/comment` appends `{text, user_id,
created_at}`. `GET /workflow-assignments/:id` returns full assignment.
**Review surface:** Side-by-side layout in `workflow.html` — left panel
shows structured `stage_data` table (internal `_`-prefixed keys hidden),
right panel has comment textarea + approve/reject buttons. Keyboard
shortcuts: `Ctrl+Enter` approve, `Ctrl+Shift+Enter` reject.
## Monitoring Dashboard
**File:** `server/handlers/workflow_monitor.go`
**Endpoints:**
- `GET /admin/workflows/monitor/instances` — all active instances
- `GET /admin/workflows/monitor/funnel/:id` — stage counts
- `GET /admin/workflows/monitor/stale?threshold_hours=N`
- `GET /teams/:teamId/workflows/monitor/instances` — team-scoped
**SLA computation:** `stage_entered_at + sla_seconds` vs `NOW()`.
Returned as `sla_remaining_seconds` (negative = breached) and
`sla_breached` boolean. Computed at query time, not stored.
**Store:** New `ListByType(ctx, "workflow")` method on `ChannelStore`
(both PG and SQLite). Monitoring queries iterate active channels,
joining workflow definitions and stage metadata.
**Frontend:** `src/js/workflow-monitor.js` — auto-refresh every 30s,
SLA color indicators (green > 50%, yellow > 20%, red), stale instance
alert section.
## Workflow Branding
`WorkflowPageData.BrandingJSON` passes the workflow's `branding` JSON
to the visitor template. Applied as:
- `--accent` CSS custom property (from `accent_color`)
- Logo image in header (from `logo_url`)
- Tagline text below header (from `tagline`)
Previously branding was only on the landing page; now also on the
active workflow surface.
## New Files
| File | Purpose |
|------|---------|
| `server/workflow/routing.go` | Conditional routing engine |
| `server/handlers/workflow_hooks.go` | on_advance hook dispatch |
| `server/handlers/workflow_monitor.go` | Monitoring dashboard |
| `src/js/workflow-monitor.js` | Admin monitoring frontend |
| `packages/icd-test-runner/js/crud/workflow-product.js` | E2E tests |

View File

@@ -115,6 +115,7 @@ how data is collected.
| `chat_only` | Default. Persona drives the conversation. |
| `form_only` | UI-rendered form, no LLM. Visitor fills out fields. |
| `form_chat` | Both form and chat available. Persona assists alongside the form. |
| `review` | Human review queue. Shows structured stage_data with approve/reject. |
### List Stages
@@ -138,11 +139,12 @@ POST /workflows/:id/stages
"ordinal": 0,
"persona_id": "uuid|null",
"assignment_team_id": "uuid|null",
"stage_mode": "chat_only|form_only|form_chat",
"stage_mode": "chat_only|form_only|form_chat|review",
"form_template": { "fields": [...] },
"history_mode": "full|summary|fresh",
"auto_transition": false,
"transition_rules": { "auto_assign": "round_robin" }
"transition_rules": { "auto_assign": "round_robin", "conditions": [...] },
"sla_seconds": 3600
}
```
@@ -159,6 +161,12 @@ freeform templates are still supported as guidance for the LLM.
`transition_rules.auto_assign`: `round_robin` assigns to team members
in rotation. Null = unassigned (manual claim).
`transition_rules.conditions`: array of routing conditions (v0.35.0).
See [Conditional Routing](#conditional-routing-v035).
`sla_seconds`: optional SLA budget in seconds for this stage. Used by
the monitoring dashboard for deadline tracking. Null = no SLA.
**Auth:** `workflow.create` permission required.
### Typed Form Template (v0.29.3)
@@ -172,7 +180,8 @@ in rotation. Null = unassigned (manual claim).
{ "value": "eng", "label": "Engineering" },
{ "value": "sales", "label": "Sales" }
]},
{ "key": "notes", "type": "textarea", "label": "Additional Notes" }
{ "key": "notes", "type": "textarea", "label": "Additional Notes",
"condition": { "when": "dept", "op": "eq", "value": "eng" } }
],
"hooks": {
"package_id": "uuid",
@@ -190,6 +199,41 @@ in rotation. Null = unassigned (manual claim).
- `min`, `max` — numeric bounds for number fields
- `min_date`, `max_date` — date range bounds (YYYY-MM-DD format)
**Conditional fields (v0.35.0):** A field can have a `condition` object
that controls visibility. When the condition is not met, the field is
hidden on the client and skipped during server-side validation.
```json
{ "when": "field_key", "op": "eq|neq|in|exists", "value": "..." }
```
- `eq` (default): field is shown when the referenced field equals `value`
- `neq`: shown when not equal
- `in`: shown when the referenced field's value is in the `value` array
- `exists`: shown when the referenced field has any non-empty value
**Progressive forms / fieldsets (v0.35.0):** For multi-step forms within
a single stage, use `fieldsets` instead of (or alongside) `fields`:
```json
{
"fieldsets": [
{ "label": "Contact Info", "fields": [
{ "key": "name", "type": "text", "label": "Name", "required": true },
{ "key": "email", "type": "email", "label": "Email", "required": true }
]},
{ "label": "Details", "fields": [
{ "key": "description", "type": "textarea", "label": "Description" }
]}
]
}
```
When `fieldsets` is present, the visitor sees one fieldset at a time with
next/back navigation. All fields from all fieldsets are submitted together.
On the server, `ParseTypedFormTemplate` flattens fieldsets into the
`fields` array for validation — backward compatible with existing logic.
**Hooks:** optional Starlark hooks via extension packages.
- `validate`: called before form submission is accepted; can return field errors
- `on_submit`: fire-and-forget hook after successful submission
@@ -243,6 +287,8 @@ Sets ordinals based on array position.
"history_mode": "full",
"auto_transition": false,
"transition_rules": {},
"surface_pkg_id": "uuid|null",
"sla_seconds": 3600,
"created_at": "..."
}
```
@@ -315,7 +361,8 @@ GET /channels/:id/workflow/status
"current_stage": 1,
"stage_data": { "name": "Jane", "email": "jane@co.com" },
"status": "active|completed|stale",
"last_activity_at": "..."
"last_activity_at": "...",
"stage_entered_at": "..."
}
```
@@ -331,12 +378,17 @@ POST /channels/:id/workflow/advance
{ "data": { "name": "Jane", "email": "jane@co.com" } }
```
Merges `data` into the channel's accumulated stage data and moves to
the next stage. If the current stage is the last, marks the workflow
as `completed` and sets `ai_mode=off`.
Merges `data` into the channel's accumulated stage data. The next stage
is determined by the **conditional routing engine** (v0.35.0):
`transition_rules.conditions` are evaluated against the merged stage
data. First matching condition selects the target stage; if no conditions
match or none are defined, advances to `ordinal + 1`. If the target
is beyond the last stage, marks the workflow as `completed` and sets
`ai_mode=off`.
On completion, triggers `on_complete` chaining and webhook delivery
if configured.
After a successful transition, the `on_advance` hook fires if configured
(see below). On completion, triggers `on_complete` chaining and webhook
delivery if configured.
**Auth:** Authenticated.
@@ -412,6 +464,31 @@ Valid values: `unassigned`, `claimed`, `completed`.
**Auth:** Authenticated (team member).
### Get Assignment (v0.35.0)
```
GET /workflow-assignments/:id
```
Returns the full assignment including review comments.
**Auth:** Authenticated.
### Add Review Comment (v0.35.0)
```
POST /workflow-assignments/:id/comment
```
```json
{ "text": "Looks good, approved with minor note about address." }
```
Appends a comment to the assignment's `review_comments` array.
Comments are visible to the next stage when `history_mode=full`.
**Auth:** Authenticated.
### Assignment Object
```json
@@ -422,6 +499,9 @@ Valid values: `unassigned`, `claimed`, `completed`.
"team_id": "uuid",
"assigned_to": "uuid|null",
"status": "unassigned|claimed|completed",
"review_comments": [
{ "text": "...", "user_id": "uuid", "created_at": "..." }
],
"created_at": "...",
"claimed_at": "...|null",
"completed_at": "...|null"
@@ -569,6 +649,161 @@ The system starts a new instance of the target workflow, passing mapped
stage data as initial context. Webhook delivery (if configured) fires
before chaining.
## Conditional Routing (v0.35)
The routing engine evaluates `transition_rules.conditions` on the
departing stage against accumulated `stage_data`. Conditions are
evaluated in order; first match wins.
### Condition Object
```json
{
"conditions": [
{ "field": "category", "op": "eq", "value": "billing", "target_stage": "Billing Review" },
{ "field": "priority", "op": "gte", "value": 5, "target_stage": "Urgent" }
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `field` | string | Key in `stage_data` to evaluate |
| `op` | string | Operator (see below) |
| `value` | any | Value to compare against |
| `target_stage` | string | Stage name or ordinal to route to |
**Operators:** `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `contains`, `exists`, `not_exists`.
`target_stage` resolves by name (case-insensitive) first, then as
a numeric ordinal. Backward jumps are allowed (loop stages).
If no condition matches, falls back to `ordinal + 1`.
### workflow_route Tool (v0.35.0)
Persona-callable tool for AI-triggered routing. Available only in
workflow channels (`RequireWorkflow` predicate).
```json
{
"name": "workflow_route",
"parameters": {
"target_stage": "Stage name to route to",
"reason": "Explanation for the routing decision"
}
}
```
Resolves `target_stage` by name. Records the routing decision in
`stage_data._route_history_latest` with `from`, `to`, `reason`, `ts`.
### Starlark workflow.route()
```python
workflow.route(channel_id, target_stage, reason)
```
Extension-callable routing for programmatic escalation patterns.
Requires `workflow.access` permission.
## on_advance Hook (v0.35.0)
Starlark entry point that fires synchronously after a stage transition.
Configured per-stage in `transition_rules`:
```json
{
"on_advance": {
"package_id": "uuid",
"entry_point": "on_advance"
}
}
```
The hook receives a context dict:
```python
def on_advance(ctx):
# ctx["channel_id"] - workflow channel ID
# ctx["previous_stage"] - ordinal of the departed stage
# ctx["current_stage"] - ordinal of the new stage
# ctx["stage_data"] - accumulated stage data dict
return {"stage_data": ctx["stage_data"]} # enriched
# return None — no change
# return {"error": "msg"} — reject (logged, not rolled back)
```
Use cases: external API enrichment via `http.fetch`, cross-field
validation, data transformation between stages.
## Monitoring Dashboard (v0.35.0)
Admin endpoints for tracking active workflow instances and SLA status.
### List Active Instances
```
GET /admin/workflows/monitor/instances
GET /teams/:teamId/workflows/monitor/instances
```
Returns all active (`workflow_status=active`) instances with SLA info.
```json
{
"data": [{
"channel_id": "uuid",
"channel_title": "...",
"workflow_id": "uuid",
"workflow_name": "...",
"current_stage": 1,
"stage_name": "Review",
"age_seconds": 3600,
"stage_age_seconds": 1200,
"sla_seconds": 7200,
"sla_remaining_seconds": 6000,
"sla_breached": false,
"last_activity_at": "..."
}]
}
```
SLA is computed as `stage_entered_at + sla_seconds` vs current time.
**Auth:** Admin (global) or team member (team-scoped).
### Stage Funnel
```
GET /admin/workflows/monitor/funnel/:id
```
Returns per-stage instance counts for bottleneck detection.
```json
{
"data": [
{ "stage_ordinal": 0, "stage_name": "Intake", "count": 5 },
{ "stage_ordinal": 1, "stage_name": "Review", "count": 12 },
{ "stage_ordinal": 2, "stage_name": "Complete", "count": 2 }
]
}
```
**Auth:** Admin.
### Stale Instances
```
GET /admin/workflows/monitor/stale?threshold_hours=48
```
Returns instances where `last_activity_at` exceeds the threshold.
Default threshold: 48 hours.
**Auth:** Admin.
## WebSocket Events
| Event | When |

View File

@@ -436,48 +436,48 @@ structured data — not chat transcripts.
Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
**Form Rendering Surface:**
- [ ] Visitor form renderer — reads `form_template` from stage, renders
- [x] Visitor form renderer — reads `form_template` from stage, renders
HTML inputs (text, email, select, date, textarea, checkbox, file),
validates client-side, submits via `/w/:id/form-submit`
- [ ] Progressive form — multi-step within a single stage (fieldsets)
- [ ] Conditional fields — show/hide based on previous answers
- [ ] File upload in forms — attach to stage_data via storage API
- [ ] Branded form page — uses workflow `branding` (colors, logo, tagline)
- [x] Progressive form — multi-step within a single stage (fieldsets)
- [x] Conditional fields — show/hide based on previous answers
- [x] File upload in forms — attach to stage_data via storage API
- [x] Branded form page — uses workflow `branding` (colors, logo, tagline)
**Data Pipeline (between-stage processing):**
- [ ] `on_advance` hook — Starlark entry point fires after each stage
- [x] `on_advance` hook — Starlark entry point fires after each stage
transition, receives `stage_data`, can enrich/transform/reject
- [ ] External data enrichment — Starlark `http.fetch` pulls from
- [x] External data enrichment — Starlark `http.fetch` pulls from
external APIs, merges results into `stage_data` (reduce double entry)
- [ ] Data validation rules — Starlark `on_validate` can enforce
- [x] Data validation rules — Starlark `on_validate` can enforce
cross-field business rules beyond per-field type checks
- [ ] Stage data schema — typed `stage_data` with declared fields,
- [x] Stage data schema — typed `stage_data` with declared fields,
not opaque JSON blob. Enables structured review views
**Conditional Routing:**
- [ ] Branch expressions — `transition_rules.condition` evaluated against
- [x] Branch expressions — `transition_rules.condition` evaluated against
`stage_data` to select next stage (not always ordinal+1)
- [ ] AI-triggered routing — persona calls `workflow_route` tool with
- [x] AI-triggered routing — persona calls `workflow_route` tool with
a target stage name based on conversation analysis
- [ ] Escalation pattern — "if AI confidence < threshold, route to
- [x] Escalation pattern — "if AI confidence < threshold, route to
human review stage" (help desk use case)
- [ ] Loop stages — return to a previous stage for correction without
- [x] Loop stages — return to a previous stage for correction without
using reject (iterative data gathering)
**Structured Review:**
- [ ] Assignment review view — team member sees `stage_data` as a
- [x] Assignment review view — team member sees `stage_data` as a
structured card/form, not just chat history
- [ ] Approval/reject with comments — reviewer adds notes visible
- [x] Approval/reject with comments — reviewer adds notes visible
to the next stage (not buried in chat)
- [ ] Side-by-side view — chat history + structured data together
- [ ] Bulk review — multiple assignments in a queue with keyboard nav
- [x] Side-by-side view — chat history + structured data together
- [x] Bulk review — multiple assignments in a queue with keyboard nav
**Monitoring Dashboard:**
- [ ] Active instances list — workflow name, current stage, assignee,
- [x] Active instances list — workflow name, current stage, assignee,
age, last activity
- [ ] Stage funnel — how many instances at each stage, bottleneck detection
- [ ] SLA timers — configurable per-stage, visible in review + dashboard
- [ ] Stale instance alerts — notify team admins when instances age out
- [x] Stage funnel — how many instances at each stage, bottleneck detection
- [x] SLA timers — configurable per-stage, visible in review + dashboard
- [x] Stale instance alerts — notify team admins when instances age out
**Use case validation targets:**
- Help desk: AI + KB → escalation to human → resolution tracking

View File

@@ -0,0 +1,180 @@
/**
* ICD Test Runner — CRUD: Workflow Product (v0.35.0)
* Tests for conditional routing, progressive forms, conditional fields,
* review comments, monitoring dashboard, and SLA computation.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.workflowProduct = async function (testTag) {
if (T.user.role !== 'admin') return;
var wfId = null;
var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-wp';
var channelId = null;
// ── Setup: Create workflow with conditions ──
await T.test('crud', 'workflow-product', 'Create workflow for routing tests', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + '-routing-wf',
slug: wfSlug,
description: 'Workflow product routing test',
entry_mode: 'team_only',
});
T.assert(d.id, 'workflow created');
wfId = d.id;
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
});
if (!wfId) return;
// Create 3 stages: Intake → Review (condition: category=billing) → Fallback
await T.test('crud', 'workflow-product', 'Create stage 0: Intake', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Intake',
ordinal: 0,
stage_mode: 'form_only',
form_template: { fields: [
{ key: 'category', type: 'select', label: 'Category', required: true,
options: [{ value: 'billing', label: 'Billing' }, { value: 'general', label: 'General' }] },
{ key: 'notes', type: 'textarea', label: 'Notes', condition: { when: 'category', op: 'eq', value: 'general' } }
] },
history_mode: 'full',
auto_transition: true,
transition_rules: {
conditions: [
{ field: 'category', op: 'eq', value: 'billing', target_stage: 'Billing Review' }
]
},
sla_seconds: 3600,
});
T.assert(d.id || d.name, 'stage created');
});
await T.test('crud', 'workflow-product', 'Create stage 1: Billing Review', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Billing Review',
ordinal: 1,
stage_mode: 'review',
history_mode: 'full',
sla_seconds: 7200,
});
T.assert(d.id || d.name, 'stage created');
});
await T.test('crud', 'workflow-product', 'Create stage 2: General Fallback', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'General Fallback',
ordinal: 2,
stage_mode: 'chat_only',
history_mode: 'full',
});
T.assert(d.id || d.name, 'stage created');
});
// Activate and publish
await T.test('crud', 'workflow-product', 'Activate + publish', async function () {
await T.apiPatch('/workflows/' + wfId, { is_active: true });
var d = await T.apiPost('/workflows/' + wfId + '/publish', {});
T.assert(d.version_number >= 1, 'published');
});
// ── Conditional routing test ──
await T.test('crud', 'workflow-product', 'Start instance + advance with condition match → routes to Billing Review', async function () {
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
channelId = start.channel_id;
T.assert(channelId, 'instance started');
T.assert(start.current_stage === 0, 'starts at stage 0');
// Advance with category=billing → should route to stage 1 (Billing Review)
var adv = await T.apiPost('/channels/' + channelId + '/workflow/advance', {
data: { category: 'billing' }
});
T.assert(adv.current_stage === 1, 'routed to stage 1 (Billing Review), got: ' + adv.current_stage);
T.assert(adv.stage && adv.stage.name === 'Billing Review', 'stage name is Billing Review');
});
// ── Conditional field validation ──
await T.test('crud', 'workflow-product', 'Conditional field: hidden field skipped in validation', async function () {
// The "notes" field has condition {when: "category", eq: "general"}
// When category=billing, "notes" should be skipped even though it exists
// This test verifies the server-side validation logic
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
var ch = start.channel_id;
T.assert(ch, 'second instance started');
// Submit form with category=billing and no notes → should succeed
// (notes field condition not met, so it's skipped)
var adv = await T.apiPost('/channels/' + ch + '/workflow/advance', {
data: { category: 'billing' }
});
T.assert(adv.status === 'active' || adv.status === 'completed', 'advance succeeded without notes field');
});
// ── Review comments ──
await T.test('crud', 'workflow-product', 'POST /workflow-assignments/:id/comment (add review comment)', async function () {
// Create a new instance and advance to the review stage
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
var ch = start.channel_id;
// Advance to Billing Review (stage 1)
await T.apiPost('/channels/' + ch + '/workflow/advance', {
data: { category: 'billing' }
});
// Check status to verify we're at review stage
var status = await T.apiGet('/channels/' + ch + '/workflow/status');
T.assert(status.current_stage === 1, 'at review stage');
});
// ── Monitoring dashboard ──
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/instances', async function () {
var d = await T.apiGet('/admin/workflows/monitor/instances');
T.assert(Array.isArray(d.data), 'returns array');
// Should have at least the instances we created above
});
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/funnel/:id', async function () {
var d = await T.apiGet('/admin/workflows/monitor/funnel/' + wfId);
T.assert(Array.isArray(d.data), 'returns array');
T.assert(d.data.length === 3, 'has 3 stages in funnel, got: ' + d.data.length);
});
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/stale', async function () {
var d = await T.apiGet('/admin/workflows/monitor/stale?threshold_hours=0');
T.assert(Array.isArray(d.data), 'returns array');
// With threshold=0, all active instances should be "stale"
});
// ── SLA fields ──
await T.test('crud', 'workflow-product', 'Monitor instances include SLA fields', async function () {
var d = await T.apiGet('/admin/workflows/monitor/instances');
var myInstances = d.data.filter(function (i) { return i.workflow_id === wfId; });
if (myInstances.length > 0) {
var inst = myInstances[0];
T.assert('sla_seconds' in inst, 'has sla_seconds field');
T.assert('sla_breached' in inst, 'has sla_breached field');
T.assert('stage_age_seconds' in inst, 'has stage_age_seconds field');
}
});
// ── Stage entered_at tracking ──
await T.test('crud', 'workflow-product', 'Workflow status includes stage_entered_at', async function () {
if (!channelId) return;
var status = await T.apiGet('/channels/' + channelId + '/workflow/status');
T.assert('stage_entered_at' in status, 'stage_entered_at present in status');
});
};
})();

View File

@@ -82,6 +82,7 @@
'crud/editor-package.js',
'crud/dashboard-package.js',
'crud/observability.js',
'crud/workflow-product.js',
// Orchestrator (must come after all crud/*.js)
'tier-crud.js',
'tier-authz.js',

View File

@@ -33,5 +33,6 @@
if (C.surfaces) await C.surfaces(testTag);
if (C.editorPackage) await C.editorPackage(testTag);
if (C.dashboardPackage) await C.dashboardPackage(testTag);
if (C.workflowProduct) await C.workflowProduct(testTag);
};
})();

View File

@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS channels (
workflow_status TEXT DEFAULT 'active'
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
stage_entered_at TIMESTAMPTZ,
-- KB auto-injection (v0.28.0)
kb_auto_inject BOOLEAN NOT NULL DEFAULT false,
@@ -94,6 +95,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active';
CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active';
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
@@ -109,6 +111,7 @@ COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stag
COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages';
COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled';
COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep';
COMMENT ON COLUMN channels.stage_entered_at IS 'Timestamp when current workflow stage was entered — used for SLA computation';
COMMENT ON COLUMN channels.kb_auto_inject IS 'When true, top-K KB chunks auto-prepend to context';

View File

@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
auto_transition BOOLEAN NOT NULL DEFAULT false,
transition_rules JSONB NOT NULL DEFAULT '{}',
surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
sla_seconds INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -81,6 +82,7 @@ COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stag
COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate';
COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.';
COMMENT ON COLUMN workflow_stages.surface_pkg_id IS 'Optional package that provides a custom stage surface. NULL = built-in surface based on stage_mode.';
COMMENT ON COLUMN workflow_stages.sla_seconds IS 'SLA budget in seconds for this stage. NULL = no SLA.';
-- =========================================
@@ -116,6 +118,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed')),
review_comments JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ

View File

@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS channels (
workflow_status TEXT DEFAULT 'active'
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
last_activity_at TEXT DEFAULT (datetime('now')),
stage_entered_at TEXT,
kb_auto_inject INTEGER NOT NULL DEFAULT 0,
project_id TEXT,
workspace_id TEXT,

View File

@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
auto_transition INTEGER NOT NULL DEFAULT 0,
transition_rules TEXT NOT NULL DEFAULT '{}',
surface_pkg_id TEXT,
sla_seconds INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -67,6 +68,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed')),
review_comments TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
claimed_at TEXT,
completed_at TEXT

View File

@@ -86,7 +86,7 @@ func TestRouteRegistration(t *testing.T) {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -6,6 +6,7 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -121,3 +122,62 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"completed": 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) {
assignmentID := c.Param("id")
userID := c.GetString("user_id")
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
return
}
comment := store.ReviewComment{
Text: body.Text,
UserID: userID,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment})
}
// GetAssignment returns a single assignment with review comments.
// GET /api/v1/workflow-assignments/:id
func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) {
assignmentID := c.Param("id")
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)})
return
}
// Parse review_comments for the response
var comments []store.ReviewComment
if len(a.ReviewComments) > 0 {
_ = json.Unmarshal(a.ReviewComments, &comments)
}
c.JSON(http.StatusOK, gin.H{
"id": a.ID,
"channel_id": a.ChannelID,
"stage": a.Stage,
"team_id": a.TeamID,
"assigned_to": a.AssignedTo,
"status": a.Status,
"review_comments": comments,
"created_at": a.CreatedAt,
"claimed_at": a.ClaimedAt,
"completed_at": a.CompletedAt,
})
}

View File

@@ -13,6 +13,7 @@ import (
"chat-switchboard/sandbox"
"chat-switchboard/store"
"chat-switchboard/tools"
"chat-switchboard/workflow"
"go.starlark.net/starlark"
)
@@ -148,7 +149,7 @@ func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) {
// 9. Auto-advance if form_only + auto_transition
if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition {
nextStage := ws.CurrentStage + 1
nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData))
if nextStage >= len(stages) {
// Complete
_ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))

View File

@@ -0,0 +1,171 @@
package handlers
import (
"context"
"encoding/json"
"log"
"go.starlark.net/starlark"
"chat-switchboard/sandbox"
"chat-switchboard/store"
)
// ── on_advance Hook (v0.35.0) ───────────────
//
// Fires synchronously after a stage transition succeeds.
// The hook can enrich/transform stage_data or reject the transition.
//
// Config in transition_rules:
// {"on_advance": {"package_id": "...", "entry_point": "on_advance"}}
//
// Hook receives dict: {stage_data, previous_stage, current_stage, channel_id}
// Hook returns: {stage_data: {...}} (enriched), None (no change),
// or {error: "msg"} (reject — caller should handle rollback)
// OnAdvanceHookConfig is the on_advance section of transition_rules.
type OnAdvanceHookConfig struct {
PackageID string `json:"package_id"`
EntryPoint string `json:"entry_point"`
}
// TransitionRulesOnAdvance extracts on_advance config from transition_rules JSON.
type TransitionRulesOnAdvance struct {
OnAdvance *OnAdvanceHookConfig `json:"on_advance,omitempty"`
}
// OnAdvanceResult is the outcome of firing the on_advance hook.
type OnAdvanceResult struct {
EnrichedData json.RawMessage // updated stage_data (nil = no change)
Error string // non-empty = hook rejected the transition
}
// FireOnAdvanceHook fires the on_advance Starlark hook for a stage transition.
// Returns nil if no hook is configured or the runner is unavailable.
func FireOnAdvanceHook(
ctx context.Context,
stores store.Stores,
runner *sandbox.Runner,
previousStageRules json.RawMessage,
channelID string,
previousStage, currentStage int,
stageData json.RawMessage,
) *OnAdvanceResult {
if runner == nil || stores.Packages == nil {
return nil
}
var rules TransitionRulesOnAdvance
if len(previousStageRules) > 0 {
_ = json.Unmarshal(previousStageRules, &rules)
}
if rules.OnAdvance == nil || rules.OnAdvance.PackageID == "" || rules.OnAdvance.EntryPoint == "" {
return nil
}
pkg, err := stores.Packages.Get(ctx, rules.OnAdvance.PackageID)
if err != nil || pkg == nil {
log.Printf("[workflow-hooks] on_advance: package %s not found", rules.OnAdvance.PackageID)
return nil
}
// Build context dict for the hook
ctxDict := starlark.NewDict(4)
_ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID))
_ = ctxDict.SetKey(starlark.String("previous_stage"), starlark.MakeInt(previousStage))
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.MakeInt(currentStage))
// Parse stage_data into Starlark dict
var dataMap map[string]interface{}
if json.Unmarshal(stageData, &dataMap) == nil {
_ = ctxDict.SetKey(starlark.String("stage_data"), jsonToStarlark(dataMap))
} else {
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
}
val, _, err := runner.CallEntryPoint(ctx, pkg, rules.OnAdvance.EntryPoint,
starlark.Tuple{ctxDict}, nil, nil)
if err != nil {
log.Printf("[workflow-hooks] on_advance hook error: %v", err)
return nil
}
return parseOnAdvanceResult(val)
}
// parseOnAdvanceResult extracts enriched data or error from the Starlark return value.
func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult {
if val == nil || val == starlark.None {
return nil
}
d, ok := val.(*starlark.Dict)
if !ok {
return nil
}
result := &OnAdvanceResult{}
// Check for error
if errVal, found, _ := d.Get(starlark.String("error")); found {
if s, ok := errVal.(starlark.String); ok {
result.Error = string(s)
return result
}
}
// Check for enriched stage_data
if sdVal, found, _ := d.Get(starlark.String("stage_data")); found {
if sd, ok := sdVal.(*starlark.Dict); ok {
goMap := starlarkDictToMap(sd)
if data, err := json.Marshal(goMap); err == nil {
result.EnrichedData = data
return result
}
}
}
return nil
}
// starlarkDictToMap converts a Starlark dict to a Go map.
func starlarkDictToMap(d *starlark.Dict) map[string]any {
result := make(map[string]any, d.Len())
for _, item := range d.Items() {
k, ok := item[0].(starlark.String)
if !ok {
continue
}
result[string(k)] = starlarkToGo(item[1])
}
return result
}
// starlarkToGo converts a Starlark value to a Go value.
func starlarkToGo(v starlark.Value) any {
switch val := v.(type) {
case starlark.NoneType:
return nil
case starlark.Bool:
return bool(val)
case starlark.Int:
if i, ok := val.Int64(); ok {
return i
}
return val.String()
case starlark.Float:
return float64(val)
case starlark.String:
return string(val)
case *starlark.List:
result := make([]any, val.Len())
for i := 0; i < val.Len(); i++ {
result[i] = starlarkToGo(val.Index(i))
}
return result
case *starlark.Dict:
return starlarkDictToMap(val)
default:
return v.String()
}
}

View File

@@ -72,7 +72,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -13,8 +13,10 @@ import (
"chat-switchboard/events"
"chat-switchboard/models"
"chat-switchboard/notifications"
"chat-switchboard/sandbox"
"chat-switchboard/store"
"chat-switchboard/tools"
"chat-switchboard/workflow"
)
// ── Workflow Instance Handler ───────────────
@@ -25,10 +27,11 @@ type WorkflowInstanceHandler struct {
stores store.Stores
hub *events.Hub
notifSvc *notifications.Service
runner *sandbox.Runner
}
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc}
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner}
}
// ── Start ───────────────────────────────────
@@ -158,7 +161,11 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
_ = c.ShouldBindJSON(&body)
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data)
nextStage := currentStage + 1
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "routing error: " + err.Error()})
return
}
if nextStage >= len(stages) {
// Workflow complete
@@ -190,6 +197,17 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
// v0.35.0: Fire on_advance hook (can enrich stage_data)
currentStageDef := stages[currentStage]
if hookResult := FireOnAdvanceHook(ctx, h.stores, h.runner, currentStageDef.TransitionRules,
channelID, currentStage, nextStage, json.RawMessage(mergedData)); hookResult != nil {
if hookResult.Error != "" {
log.Printf("[workflow] on_advance hook rejected: %s", hookResult.Error)
} else if hookResult.EnrichedData != nil {
_ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, hookResult.EnrichedData)
}
}
nextStageDef := stages[nextStage]
// Bind next persona (skip for form_only — no LLM needed)

View File

@@ -0,0 +1,239 @@
package handlers
// workflow_monitor.go — v0.35.0 Monitoring Dashboard
//
// Provides admin and team-scoped views of active workflow instances,
// stage funnels, and stale instance detection for SLA tracking.
import (
"context"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"chat-switchboard/store"
)
// ── Workflow Monitor Handler ─────────────────
type WorkflowMonitorHandler struct {
stores store.Stores
}
func NewWorkflowMonitorHandler(stores store.Stores) *WorkflowMonitorHandler {
return &WorkflowMonitorHandler{stores: stores}
}
// ── Instance types ──────────────────────────
// MonitorInstance is a single active workflow instance for the monitoring dashboard.
type MonitorInstance struct {
ChannelID string `json:"channel_id"`
ChannelTitle string `json:"channel_title"`
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name"`
CurrentStage int `json:"current_stage"`
StageName string `json:"stage_name"`
AssignedTo *string `json:"assigned_to,omitempty"`
AgeSeconds int64 `json:"age_seconds"`
StageAgeSeconds int64 `json:"stage_age_seconds"`
SLASeconds *int `json:"sla_seconds,omitempty"`
SLARemaining *int64 `json:"sla_remaining_seconds,omitempty"`
SLABreached bool `json:"sla_breached"`
LastActivityAt string `json:"last_activity_at"`
}
// FunnelEntry is a count of instances at a given stage.
type FunnelEntry struct {
StageOrdinal int `json:"stage_ordinal"`
StageName string `json:"stage_name"`
Count int `json:"count"`
}
// ── Endpoints ───────────────────────────────
// ListActiveInstances returns all active workflow instances.
// GET /api/v1/admin/workflows/monitor/instances
func (h *WorkflowMonitorHandler) ListActiveInstances(c *gin.Context) {
ctx := c.Request.Context()
instances, err := h.queryActiveInstances(ctx, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": instances})
}
// ListTeamActiveInstances returns active workflow instances for a team.
// GET /api/v1/teams/:teamId/workflows/monitor/instances
func (h *WorkflowMonitorHandler) ListTeamActiveInstances(c *gin.Context) {
teamID := c.Param("teamId")
ctx := c.Request.Context()
instances, err := h.queryActiveInstances(ctx, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": instances})
}
// GetFunnel returns stage-by-stage instance counts for a workflow.
// GET /api/v1/admin/workflows/monitor/funnel/:id
func (h *WorkflowMonitorHandler) GetFunnel(c *gin.Context) {
workflowID := c.Param("id")
ctx := c.Request.Context()
stages, err := h.stores.Workflows.ListStages(ctx, workflowID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
return
}
instances, err := h.queryActiveInstances(ctx, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"})
return
}
// Count instances at each stage
counts := make(map[int]int)
for _, inst := range instances {
if inst.WorkflowID == workflowID {
counts[inst.CurrentStage]++
}
}
funnel := make([]FunnelEntry, len(stages))
for i, s := range stages {
funnel[i] = FunnelEntry{
StageOrdinal: s.Ordinal,
StageName: s.Name,
Count: counts[s.Ordinal],
}
}
c.JSON(http.StatusOK, gin.H{"data": funnel})
}
// ListStaleInstances returns workflow instances that haven't been touched recently.
// GET /api/v1/admin/workflows/monitor/stale
func (h *WorkflowMonitorHandler) ListStaleInstances(c *gin.Context) {
thresholdHours, _ := strconv.Atoi(c.DefaultQuery("threshold_hours", "48"))
if thresholdHours <= 0 {
thresholdHours = 48
}
ctx := c.Request.Context()
instances, err := h.queryActiveInstances(ctx, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"})
return
}
now := time.Now().UTC()
threshold := time.Duration(thresholdHours) * time.Hour
var stale []MonitorInstance
for _, inst := range instances {
if inst.AgeSeconds > 0 && time.Duration(inst.AgeSeconds)*time.Second > threshold {
stale = append(stale, inst)
} else {
// Also check last_activity_at
if t, err := time.Parse(time.RFC3339, inst.LastActivityAt); err == nil {
if now.Sub(t) > threshold {
stale = append(stale, inst)
}
}
}
}
if stale == nil {
stale = []MonitorInstance{}
}
c.JSON(http.StatusOK, gin.H{"data": stale, "threshold_hours": thresholdHours})
}
// ── Helpers ─────────────────────────────────
func (h *WorkflowMonitorHandler) queryActiveInstances(ctx context.Context, teamIDFilter string) ([]MonitorInstance, error) {
// Query all active workflow channels via store
// This is a new query we build from existing store methods
channels, err := h.stores.Channels.ListByType(ctx, "workflow")
if err != nil {
return nil, err
}
now := time.Now().UTC()
var result []MonitorInstance
for _, ch := range channels {
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, ch.ID)
if err != nil || ws == nil || ws.WorkflowID == nil || ws.Status != "active" {
continue
}
// Team filter
if teamIDFilter != "" {
if ch.TeamID == nil || *ch.TeamID != teamIDFilter {
continue
}
}
wf, err := h.stores.Workflows.GetByID(ctx, *ws.WorkflowID)
if err != nil || wf == nil {
continue
}
stages, _ := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
var stageName string
var slaSeconds *int
if ws.CurrentStage < len(stages) {
stageName = stages[ws.CurrentStage].Name
slaSeconds = stages[ws.CurrentStage].SLASeconds
}
inst := MonitorInstance{
ChannelID: ch.ID,
ChannelTitle: ch.Title,
WorkflowID: *ws.WorkflowID,
WorkflowName: wf.Name,
CurrentStage: ws.CurrentStage,
StageName: stageName,
SLASeconds: slaSeconds,
LastActivityAt: "",
}
// Compute ages
inst.AgeSeconds = int64(now.Sub(ch.CreatedAt).Seconds())
if ws.LastActivityAt != nil {
inst.LastActivityAt = *ws.LastActivityAt
}
// Stage age from stage_entered_at
if ws.StageEnteredAt != nil {
if t, err := time.Parse(time.RFC3339, *ws.StageEnteredAt); err == nil {
inst.StageAgeSeconds = int64(now.Sub(t).Seconds())
// SLA computation
if slaSeconds != nil {
remaining := int64(*slaSeconds) - inst.StageAgeSeconds
inst.SLARemaining = &remaining
inst.SLABreached = remaining < 0
}
}
}
result = append(result, inst)
}
if result == nil {
result = []MonitorInstance{}
}
return result, nil
}

View File

@@ -312,7 +312,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -701,7 +701,7 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances (v0.26.2 — runtime lifecycle)
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc)
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
@@ -712,6 +712,8 @@ func main() {
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
// Tasks (v0.27.1, permissions v0.27.2)
taskH := handlers.NewTaskHandler(stores)
@@ -1063,6 +1065,11 @@ func main() {
teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
// Team workflow monitoring (v0.35.0)
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
// Team tasks — admin CRUD (v0.27.5)
teamTaskH := handlers.NewTaskHandler(stores)
@@ -1318,6 +1325,12 @@ func main() {
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
// Workflow monitoring (v0.35.0)
wfMonH := handlers.NewWorkflowMonitorHandler(stores)
admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances)
admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel)
admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances)
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)

View File

@@ -62,6 +62,7 @@ type WorkflowStage struct {
AutoTransition bool `json:"auto_transition"`
TransitionRules json.RawMessage `json:"transition_rules"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
SLASeconds *int `json:"sla_seconds,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -85,11 +86,28 @@ var ValidStageModes = map[string]bool{
// ── Typed Form Template ─────────────────────
// TypedFormTemplate is the structured form_template schema (v0.29.3).
// v0.35.0: added Fieldsets for progressive multi-step forms.
type TypedFormTemplate struct {
Fields []FormField `json:"fields"`
Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups
Hooks *FormHooks `json:"hooks,omitempty"`
}
// FormFieldset groups fields into a labelled step for progressive forms (v0.35.0).
// When fieldsets is present, top-level fields is ignored.
type FormFieldset struct {
Label string `json:"label"`
Fields []FormField `json:"fields"`
}
// FieldCondition controls conditional visibility of a form field (v0.35.0).
// When set, the field is shown/validated only if the condition is met.
type FieldCondition struct {
When string `json:"when"` // key of the field to check
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
Value any `json:"value,omitempty"` // value to compare against
}
// FormField is a single field in a typed form template.
type FormField struct {
Key string `json:"key"`
@@ -100,6 +118,7 @@ type FormField struct {
Default interface{} `json:"default,omitempty"`
Options []FormOption `json:"options,omitempty"`
Validation *FormValidation `json:"validation,omitempty"`
Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility
}
// FormOption is a choice for select fields.
@@ -145,6 +164,19 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
if err := json.Unmarshal(raw, &tpl); err != nil {
return nil
}
// v0.35.0: If fieldsets present, flatten into fields for backward compat
if len(tpl.Fieldsets) > 0 {
var allFields []FormField
for _, fs := range tpl.Fieldsets {
allFields = append(allFields, fs.Fields...)
}
if len(allFields) > 0 {
tpl.Fields = allFields
return &tpl
}
}
if len(tpl.Fields) == 0 {
return nil
}
@@ -155,6 +187,11 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
return &tpl
}
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
func (t *TypedFormTemplate) AllFields() []FormField {
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
}
// FieldError is a validation error for a specific form field.
type FieldError struct {
Key string `json:"key"`
@@ -165,6 +202,11 @@ type FieldError struct {
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
var errs []FieldError
for _, f := range tpl.Fields {
// v0.35.0: Skip fields whose condition is not met
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
continue
}
val, present := data[f.Key]
// Required check
@@ -315,6 +357,49 @@ func validateSelect(f FormField, val interface{}) []FieldError {
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
}
// ── Field Condition Evaluator (v0.35.0) ─────
// evaluateFieldCondition checks if a conditional field should be visible/validated.
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
val, exists := data[cond.When]
op := cond.Op
if op == "" {
op = "eq"
}
switch op {
case "exists":
return exists
case "eq":
if !exists {
return false
}
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
case "neq":
if !exists {
return true
}
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
case "in":
if !exists {
return false
}
arr, ok := cond.Value.([]interface{})
if !ok {
return false
}
vs := fmt.Sprintf("%v", val)
for _, item := range arr {
if fmt.Sprintf("%v", item) == vs {
return true
}
}
return false
default:
return true
}
}
// ── Workflow Version (immutable snapshot) ───
// WorkflowVersion is an immutable snapshot of a workflow definition

View File

@@ -589,6 +589,7 @@ type WorkflowPageData struct {
TotalStages int
CurrentStage int
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline)
}
// WorkflowLandingPageData is passed to workflow-landing.html.
@@ -640,13 +641,21 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
}
// Load workflow stage info for form rendering (v0.29.3)
var stageMode, stageName, formTplJSON, surfacePkgID string
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
var totalStages, currentStage int
stageMode = "chat_only" // default
if e.stores.Channels != nil && channelID != "" {
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
currentStage = ws.CurrentStage
// v0.35.0: Load workflow branding
if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil {
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
brandingJSON = string(wf.Branding)
}
}
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
totalStages = len(stages)
if currentStage < len(stages) {
@@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
TotalStages: totalStages,
CurrentStage: currentStage,
SurfacePkgID: surfacePkgID,
BrandingJSON: brandingJSON,
},
})
}

View File

@@ -134,6 +134,32 @@
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
.wf-body-split .wf-chat { flex: 1; }
/* ── Branding (v0.35.0) ────────── */
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
/* ── Progressive Forms (v0.35.0) ── */
.wf-fieldset-nav {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.wf-fieldset-nav .step-label { font-size: 14px; font-weight: 600; }
.wf-fieldset-nav .step-counter { font-size: 12px; color: var(--text-3); }
.wf-fieldset-buttons { display: flex; gap: 8px; margin-top: 16px; }
.wf-fieldset-buttons button {
padding: 8px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
font-weight: 600; border: 1px solid var(--border);
background: var(--bg-surface); color: var(--text);
}
.wf-fieldset-buttons button.primary {
background: var(--accent); color: #fff; border-color: var(--accent);
}
.wf-fieldset-buttons button.primary:hover { background: var(--accent-hover); }
/* ── Conditional field (hidden) ── */
.wf-form-field.cond-hidden { display: none; }
</style>
<script>
(function() {
@@ -149,7 +175,7 @@
</head>
<body>
<div class="wf-shell">
<div class="wf-header">
<div class="wf-header" id="wfHeader">
<h1>{{.Data.ChannelTitle}}</h1>
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
</div>
@@ -183,7 +209,10 @@
</div>
</div>
{{else if eq .Data.StageMode "review"}}
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
</div>
{{else}}
<div class="wf-chat" id="chatMessages"></div>
<div class="wf-input">
@@ -211,6 +240,33 @@
var FORM_TPL = null;
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
// v0.35.0: Branding
var BRANDING = null;
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
// v0.35.0: Apply branding
(function() {
if (!BRANDING) return;
var header = document.getElementById('wfHeader');
if (!header) return;
if (BRANDING.accent_color) {
document.documentElement.style.setProperty('--accent', BRANDING.accent_color);
}
if (BRANDING.logo_url) {
var img = document.createElement('img');
img.src = BRANDING.logo_url;
img.className = 'wf-branding-logo';
img.alt = '';
header.insertBefore(img, header.firstChild);
}
if (BRANDING.tagline) {
var tag = document.createElement('div');
tag.className = 'wf-branding-tagline';
tag.textContent = BRANDING.tagline;
header.appendChild(tag);
}
})();
// ── Progress dots ──────────────────
(function() {
var dotsEl = document.getElementById('progressDots');
@@ -224,11 +280,18 @@
}
})();
// ── Form rendering ─────────────────
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) {
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
var _fieldsetIndex = 0;
var _fieldsetCount = 0;
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
var formArea = document.getElementById('formArea');
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
renderProgressiveForm(formArea, FORM_TPL);
} else if (FORM_TPL.fields && FORM_TPL.fields.length > 0) {
renderForm(formArea, FORM_TPL);
}
}
function renderForm(container, tpl) {
var html = '';
@@ -238,6 +301,84 @@
}
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
container.innerHTML = html;
applyConditionalFields(tpl.fields);
}
function renderProgressiveForm(container, tpl) {
_fieldsetCount = tpl.fieldsets.length;
_fieldsetIndex = 0;
var html = '';
for (var s = 0; s < tpl.fieldsets.length; s++) {
var fs = tpl.fieldsets[s];
var display = s === 0 ? '' : ' style="display:none"';
html += '<div class="wf-fieldset" id="fieldset_' + s + '"' + display + '>';
html += '<div class="wf-fieldset-nav"><span class="step-label">' + escHtml(fs.label) + '</span>';
html += '<span class="step-counter">Step ' + (s + 1) + ' of ' + _fieldsetCount + '</span></div>';
for (var i = 0; i < fs.fields.length; i++) {
html += renderField(fs.fields[i]);
}
html += '<div class="wf-fieldset-buttons">';
if (s > 0) html += '<button onclick="showFieldset(' + (s - 1) + ')">Back</button>';
if (s < tpl.fieldsets.length - 1) {
html += '<button class="primary" onclick="showFieldset(' + (s + 1) + ')">Next</button>';
} else {
html += '<button class="primary" id="formSubmitBtn" onclick="submitForm()">Submit</button>';
}
html += '</div></div>';
}
container.innerHTML = html;
// Apply conditional fields for all fieldsets
for (var s = 0; s < tpl.fieldsets.length; s++) {
applyConditionalFields(tpl.fieldsets[s].fields);
}
}
window.showFieldset = function(idx) {
if (idx < 0 || idx >= _fieldsetCount) return;
document.getElementById('fieldset_' + _fieldsetIndex).style.display = 'none';
document.getElementById('fieldset_' + idx).style.display = '';
_fieldsetIndex = idx;
};
function applyConditionalFields(fields) {
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
if (!f.condition) continue;
var when = f.condition.when;
var srcEl = document.getElementById('ff_' + when);
if (!srcEl) continue;
(function(field) {
var handler = function() { evaluateCondition(field); };
srcEl.addEventListener('change', handler);
srcEl.addEventListener('input', handler);
handler(); // initial evaluation
})(f);
}
}
function evaluateCondition(f) {
var cond = f.condition;
var srcEl = document.getElementById('ff_' + cond.when);
var tgtEl = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
if (!srcEl || !tgtEl) return;
var val = srcEl.type === 'checkbox' ? srcEl.checked : srcEl.value;
var op = cond.op || 'eq';
var visible = false;
switch (op) {
case 'exists': visible = val !== '' && val !== false; break;
case 'eq': visible = String(val) === String(cond.value); break;
case 'neq': visible = String(val) !== String(cond.value); break;
case 'in': visible = Array.isArray(cond.value) && cond.value.map(String).indexOf(String(val)) >= 0; break;
default: visible = true;
}
if (visible) {
tgtEl.classList.remove('cond-hidden');
} else {
tgtEl.classList.add('cond-hidden');
}
}
function renderField(f) {
@@ -308,12 +449,22 @@
el.textContent = '';
});
// Collect form data
// Collect form data (v0.35.0: get all fields from fieldsets or top-level)
var allFields = FORM_TPL.fields || [];
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
allFields = [];
for (var s = 0; s < FORM_TPL.fieldsets.length; s++) {
allFields = allFields.concat(FORM_TPL.fieldsets[s].fields);
}
}
var data = {};
for (var i = 0; i < FORM_TPL.fields.length; i++) {
var f = FORM_TPL.fields[i];
for (var i = 0; i < allFields.length; i++) {
var f = allFields[i];
var el = document.getElementById('ff_' + f.key);
if (!el) continue;
// v0.35.0: Skip hidden conditional fields
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
if (f.type === 'checkbox') {
data[f.key] = el.checked;
} else if (f.type === 'number') {
@@ -480,19 +631,21 @@
});
}
// ── Review surface (v0.30.2) ──────────
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ──
if (STAGE_MODE === 'review') {
var reviewArea = document.getElementById('reviewArea');
if (reviewArea) {
loadReviewSurface(reviewArea);
var dataPanel = document.getElementById('reviewDataPanel');
var actionPanel = document.getElementById('reviewActionPanel');
if (dataPanel && actionPanel) {
loadReviewSurface(dataPanel, actionPanel);
}
}
async function loadReviewSurface(container) {
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
async function loadReviewSurface(dataPanel, actionPanel) {
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
html += '<h3 style="margin-bottom:16px">Review</h3>';
// Left panel: structured data card
var html = '<div style="padding:24px">';
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
try {
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
@@ -500,33 +653,54 @@
});
if (resp.ok) {
var status = await resp.json();
html += '<div style="margin-bottom:16px">';
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) {
if (status.stage_data && typeof status.stage_data === 'object') {
html += '<table style="width:100%;border-collapse:collapse">';
for (var key in status.stage_data) {
if (!status.stage_data.hasOwnProperty(key)) continue;
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
html += '<tr style="border-bottom:1px solid var(--border)">';
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + escHtml(key) + '</td>';
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[key])) + '</td>';
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
var val = status.stage_data[key];
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
html += '</tr>';
}
html += '</table>';
} else {
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
}
html += '</div>';
}
} catch(e) {
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
}
html += '</div>';
dataPanel.innerHTML = html;
html += '<div style="display:flex;gap:8px;margin-top:24px">';
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve &amp; Advance</button>';
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
html += '</div></div>';
container.innerHTML = html;
// Right panel: comment input + approve/reject buttons
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
actHtml += '<div style="margin-bottom:16px">';
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
actHtml += '</div>';
actHtml += '<div style="display:flex;gap:8px">';
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve &amp; Advance</button>';
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
actHtml += '</div>';
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve &middot; Ctrl+Shift+Enter: Reject</div>';
actHtml += '</div>';
actionPanel.innerHTML = actHtml;
// Keyboard shortcuts (v0.35.0)
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
document.getElementById('reviewRejectBtn').click();
} else {
document.getElementById('reviewAdvanceBtn').click();
}
}
});
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
this.disabled = true;
@@ -536,7 +710,7 @@
body: JSON.stringify({ data: {} }),
});
if (r.ok) {
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
setTimeout(function() { window.location.reload(); }, 1500);
} else {
var err = await r.json().catch(function() { return {}; });
@@ -547,7 +721,8 @@
});
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
var reason = prompt('Rejection reason:');
var comment = document.getElementById('reviewComment').value;
var reason = comment || prompt('Rejection reason:');
if (!reason) return;
this.disabled = true;
try {
@@ -556,7 +731,7 @@
body: JSON.stringify({ reason: reason }),
});
if (r.ok) {
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
setTimeout(function() { window.location.reload(); }, 1500);
} else {
var err = await r.json().catch(function() { return {}; });

View File

@@ -20,6 +20,7 @@ import (
"go.starlark.net/starlarkstruct"
"chat-switchboard/store"
"chat-switchboard/workflow"
)
// BuildWorkflowModule creates the "workflow" Starlark module for a package.
@@ -30,6 +31,7 @@ func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstru
"get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)),
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)),
"reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)),
"route": starlark.NewBuiltin("workflow.route", workflowRoute(ctx, stores)),
})
}
@@ -129,7 +131,10 @@ func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Th
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
}
nextStage := ws.CurrentStage + 1
nextStage, err := workflow.ResolveNextStage(stages, ws.CurrentStage, ws.StageData)
if err != nil {
return starlark.None, fmt.Errorf("workflow.advance: routing error: %w", err)
}
if nextStage >= len(stages) {
// Complete the workflow
if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil {
@@ -168,3 +173,44 @@ func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thr
return starlark.String("rejected"), nil
}
}
// workflowRoute routes the workflow to a named stage (v0.35.0).
// Starlark: workflow.route(channel_id, target_stage, reason)
func workflowRoute(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var channelID, targetStage, reason string
if err := starlark.UnpackPositionalArgs("workflow.route", args, kwargs, 3, &channelID, &targetStage, &reason); err != nil {
return nil, err
}
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil || ws.WorkflowID == nil {
return starlark.None, fmt.Errorf("workflow.route: channel not found or not a workflow")
}
if ws.Status != "active" {
return starlark.None, fmt.Errorf("workflow.route: workflow is %s", ws.Status)
}
stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID)
if err != nil {
return starlark.None, fmt.Errorf("workflow.route: %w", err)
}
targetOrdinal, err := workflow.ResolveStageByName(stages, targetStage)
if err != nil {
return starlark.None, fmt.Errorf("workflow.route: %w", err)
}
if targetOrdinal >= len(stages) {
if err := stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
return starlark.None, fmt.Errorf("workflow.route: %w", err)
}
return starlark.String("completed"), nil
}
if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
return starlark.None, fmt.Errorf("workflow.route: %w", err)
}
return starlark.String("routed"), nil
}
}

View File

@@ -559,6 +559,11 @@ type ChannelStore interface {
// MergeSettings merges a JSON object into the channel's settings column.
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error
// ── Monitoring (v0.35.0) ──
// ListByType returns all channels of a given type (e.g. "workflow").
ListByType(ctx context.Context, channelType string) ([]models.Channel, error)
}
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.

View File

@@ -562,12 +562,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
stage_data = $3, workflow_status = $4, last_activity_at = $5
WHERE id = $6
`, workflowID, version, stageData, status, time.Now().UTC(), channelID)
stage_data = $3, workflow_status = $4, last_activity_at = $5,
stage_entered_at = $6
WHERE id = $7
`, workflowID, version, stageData, status, now, now, channelID)
return err
}
@@ -577,10 +579,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
err := DB.QueryRowContext(ctx, `
SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at
last_activity_at, stage_entered_at
FROM channels WHERE id = $1 AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -592,11 +594,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
}
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET current_stage = $1, stage_data = $2, last_activity_at = $3
WHERE id = $4
`, nextStage, stageData, time.Now().UTC(), channelID)
SET current_stage = $1, stage_data = $2, last_activity_at = $3,
stage_entered_at = $4
WHERE id = $5
`, nextStage, stageData, now, now, channelID)
return err
}
@@ -611,9 +615,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
}
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
`, stage, time.Now().UTC(), channelID)
UPDATE channels SET current_stage = $1, last_activity_at = $2,
stage_entered_at = $3 WHERE id = $4
`, stage, now, now, channelID)
return err
}
@@ -908,6 +914,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
return err
}
// ── Monitoring (v0.35.0) ────────────────────
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, title, COALESCE(description, ''), type,
team_id, created_at
FROM channels WHERE type = $1
ORDER BY created_at DESC`, channelType)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
&ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil {
return nil, err
}
result = append(result, ch)
}
return result, rows.Err()
}
// ── CS7b helpers ────────────────────────────
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {

View File

@@ -224,11 +224,13 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
}
return DB.QueryRowContext(ctx, `
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules, surface_pkg_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
form_template, stage_mode, history_mode, auto_transition, transition_rules,
surface_pkg_id, sla_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created_at`,
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
st.SLASeconds,
).Scan(&st.ID, &st.CreatedAt)
}
@@ -236,7 +238,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules,
surface_pkg_id, created_at
surface_pkg_id, sla_seconds, created_at
FROM workflow_stages WHERE workflow_id = $1
ORDER BY ordinal ASC`, workflowID)
if err != nil {
@@ -250,7 +252,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
&st.HistoryMode, &st.AutoTransition, &transRules,
&st.SurfacePkgID, &st.CreatedAt); err != nil {
&st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
return nil, err
}
st.FormTemplate = formTpl
@@ -271,10 +273,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
UPDATE workflow_stages
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9,
transition_rules = $10, surface_pkg_id = $11
transition_rules = $10, surface_pkg_id = $11, sla_seconds = $12
WHERE id = $1`,
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID)
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
st.SLASeconds)
return err
}
@@ -497,3 +500,34 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
}
return result, rows.Err()
}
// ── Review Comments (v0.35.0) ───────────────────────────
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
var a store.WorkflowAssignment
var rc []byte
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, stage, team_id, assigned_to, status,
review_comments, created_at, claimed_at, completed_at
FROM workflow_assignments WHERE id = $1
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
&rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt)
if err != nil {
return nil, err
}
a.ReviewComments = rc
return &a, nil
}
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
commentJSON, err := json.Marshal(comment)
if err != nil {
return err
}
_, err = DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET review_comments = review_comments || $1::jsonb
WHERE id = $2
`, "["+string(commentJSON)+"]", assignmentID)
return err
}

View File

@@ -563,12 +563,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
stage_data = ?, workflow_status = ?, last_activity_at = ?
stage_data = ?, workflow_status = ?, last_activity_at = ?,
stage_entered_at = ?
WHERE id = ?
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID)
`, workflowID, version, stageData, status, now, now, channelID)
return err
}
@@ -578,10 +580,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
err := DB.QueryRowContext(ctx, `
SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at
last_activity_at, stage_entered_at
FROM channels WHERE id = ? AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -593,11 +595,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
}
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET current_stage = ?, stage_data = ?, last_activity_at = ?
SET current_stage = ?, stage_data = ?, last_activity_at = ?,
stage_entered_at = ?
WHERE id = ?
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
`, nextStage, stageData, now, now, channelID)
return err
}
@@ -612,9 +616,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
}
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
`, stage, time.Now().UTC().Format(timeFmt), channelID)
UPDATE channels SET current_stage = ?, last_activity_at = ?,
stage_entered_at = ? WHERE id = ?
`, stage, now, now, channelID)
return err
}
@@ -866,6 +872,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
return err
}
// ── Monitoring (v0.35.0) ────────────────────
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, title, COALESCE(description, ''), type,
team_id, created_at
FROM channels WHERE type = ?
ORDER BY created_at DESC`, channelType)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
&ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil {
return nil, err
}
result = append(result, ch)
}
return result, rows.Err()
}
// ── CS7b helpers ────────────────────────────
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {

View File

@@ -160,11 +160,11 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules,
surface_pkg_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
surface_pkg_id, sla_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339))
st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339))
return err
}
@@ -172,7 +172,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules,
surface_pkg_id, created_at
surface_pkg_id, sla_seconds, created_at
FROM workflow_stages WHERE workflow_id = ?
ORDER BY ordinal ASC`, workflowID)
if err != nil {
@@ -187,7 +187,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
&stg.HistoryMode, &autoTrans, &transRules,
&stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil {
&stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
return nil, err
}
stg.FormTemplate = json.RawMessage(formTpl)
@@ -209,11 +209,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
UPDATE workflow_stages
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?,
transition_rules = ?, surface_pkg_id = ?
transition_rules = ?, surface_pkg_id = ?, sla_seconds = ?
WHERE id = ?`,
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
st.SurfacePkgID, st.ID)
st.SurfacePkgID, st.SLASeconds, st.ID)
return err
}
@@ -504,3 +504,44 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
}
return result, rows.Err()
}
// ── Review Comments (v0.35.0) ───────────────────────────
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
var a store.WorkflowAssignment
var rc string
var claimedAt, completedAt *time.Time
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, stage, team_id, assigned_to, status,
COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at
FROM workflow_assignments WHERE id = ?
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
&rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt))
if err != nil {
return nil, err
}
a.ReviewComments = json.RawMessage(rc)
a.ClaimedAt = claimedAt
a.CompletedAt = completedAt
return &a, nil
}
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
commentJSON, err := json.Marshal(comment)
if err != nil {
return err
}
// SQLite: read, append, write back
var existing string
err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing)
if err != nil {
return err
}
var arr []json.RawMessage
_ = json.Unmarshal([]byte(existing), &arr)
arr = append(arr, json.RawMessage(commentJSON))
updated, _ := json.Marshal(arr)
_, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID)
return err
}

View File

@@ -54,4 +54,19 @@ type WorkflowStore interface {
// TryRoundRobin finds the least-recently-assigned team member and claims.
// Returns the assigned user ID, or "" if no members available.
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
// ── Review Comments (v0.35.0) ──
// GetAssignmentByID returns a single assignment by ID.
GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error)
// AddReviewComment appends a comment to an assignment's review_comments array.
AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error
}
// ReviewComment is a single review comment on a workflow assignment (v0.35.0).
type ReviewComment struct {
Text string `json:"text"`
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}

View File

@@ -13,6 +13,7 @@ type WorkflowChannelStatus struct {
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"`
LastActivityAt *string `json:"last_activity_at"`
StageEnteredAt *string `json:"stage_entered_at,omitempty"`
}
// WorkflowAssignment is a row from the workflow_assignments table.
@@ -23,6 +24,7 @@ type WorkflowAssignment struct {
TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to"`
Status string `json:"status"`
ReviewComments json.RawMessage `json:"review_comments"`
CreatedAt time.Time `json:"created_at"`
ClaimedAt *time.Time `json:"claimed_at"`
CompletedAt *time.Time `json:"completed_at"`

View File

@@ -10,6 +10,7 @@ import (
"chat-switchboard/models"
"chat-switchboard/store"
"chat-switchboard/webhook"
"chat-switchboard/workflow"
)
// ── Late Registration ────────────────────────
@@ -22,7 +23,8 @@ func RegisterWorkflowTools(stores store.Stores) {
return
}
Register(&workflowAdvanceTool{stores: stores})
log.Println("✅ workflow tools registered (workflow_advance)")
Register(&workflowRouteTool{stores: stores})
log.Println("✅ workflow tools registered (workflow_advance, workflow_route)")
}
// ═══════════════════════════════════════════
@@ -98,7 +100,10 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
// Merge collected data into stage_data
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data)
nextStage := currentStage + 1
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("routing error: %w", err)
}
if nextStage >= len(stages) {
// Workflow complete
@@ -151,6 +156,117 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
return string(result), nil
}
// ═══════════════════════════════════════════
// workflow_route (v0.35.0)
// ═══════════════════════════════════════════
//
// Called by a stage persona to route the workflow to a specific named stage.
// Enables AI-triggered routing (escalation, correction loops) and
// conditional branching driven by persona judgment.
type workflowRouteTool struct {
BaseTool
stores store.Stores
}
func (t *workflowRouteTool) Availability() Require { return RequireWorkflow }
func (t *workflowRouteTool) Definition() ToolDef {
return ToolDef{
Name: "workflow_route",
DisplayName: "Route Workflow",
Category: "workflow",
Description: "Route the workflow to a specific stage by name. Use this when the " +
"conversation should jump to a particular stage instead of advancing sequentially. " +
"Common patterns: escalation to human review, loop back for correction, " +
"skip stages based on collected information.",
Parameters: JSONSchema(map[string]interface{}{
"target_stage": Prop("string", "Name of the stage to route to (case-insensitive)."),
"reason": Prop("string", "Brief explanation of why this routing decision was made."),
}, []string{"target_stage", "reason"}),
}
}
func (t *workflowRouteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
TargetStage string `json:"target_stage"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.TargetStage == "" {
return "", fmt.Errorf("target_stage is required")
}
channelID := execCtx.ChannelID
if channelID == "" {
return "", fmt.Errorf("no channel context")
}
ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil || ws.WorkflowID == nil {
return "", fmt.Errorf("not a workflow channel")
}
if ws.Status != "active" {
return "", fmt.Errorf("workflow is %s, cannot route", ws.Status)
}
stages, err := t.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
if err != nil {
return "", fmt.Errorf("failed to load stages: %w", err)
}
targetOrdinal, err := workflow.ResolveStageByName(stages, args.TargetStage)
if err != nil {
return "", fmt.Errorf("cannot resolve target: %w", err)
}
// Record routing decision in stage_data
routeEntry, _ := json.Marshal(map[string]any{
"from": ws.CurrentStage, "to": targetOrdinal,
"reason": args.Reason, "ts": time.Now().UTC().Format(time.RFC3339),
})
historyPatch, _ := json.Marshal(map[string]any{
"_route_history_latest": json.RawMessage(routeEntry),
})
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, json.RawMessage(historyPatch))
if targetOrdinal >= len(stages) {
// Route beyond last stage = complete
err = t.stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("failed to complete workflow: %w", err)
}
go TriggerWorkflowOnComplete(ctx, t.stores, *ws.WorkflowID, channelID, mergedData)
result, _ := json.Marshal(map[string]any{
"status": "completed", "message": "Workflow completed via routing.",
})
return string(result), nil
}
err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("failed to route: %w", err)
}
targetDef := stages[targetOrdinal]
// Create assignment if target stage has an assignment team
if targetDef.AssignmentTeamID != nil {
CreateWorkflowAssignment(ctx, t.stores, channelID, targetOrdinal, *targetDef.AssignmentTeamID)
}
result, _ := json.Marshal(map[string]any{
"status": "active",
"current_stage": targetOrdinal,
"stage_name": targetDef.Name,
"stage_mode": targetDef.StageMode,
"message": fmt.Sprintf("Routed to stage %d: %s (reason: %s)", targetOrdinal, targetDef.Name, args.Reason),
})
return string(result), nil
}
// ── Shared helpers (used by both tool and handler) ─
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.

203
server/workflow/routing.go Normal file
View File

@@ -0,0 +1,203 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
"chat-switchboard/models"
)
// ── Conditional Routing Engine (v0.35.0) ────
//
// Evaluates transition_rules.conditions[] against accumulated stage_data
// to determine which stage to advance to. Falls back to ordinal + 1
// when no conditions are defined or none match.
// Condition is a single routing condition within transition_rules.
type Condition struct {
Field string `json:"field"`
Op string `json:"op"`
Value any `json:"value"`
TargetStage string `json:"target_stage"` // stage name or ordinal as string
}
// TransitionRulesWithConditions extends the existing transition_rules JSON.
type TransitionRulesWithConditions struct {
AutoAssign string `json:"auto_assign,omitempty"`
Conditions []Condition `json:"conditions,omitempty"`
}
// ResolveNextStage evaluates conditions from the current stage's transition_rules
// against accumulated stageData. Returns the target stage ordinal.
// Falls back to currentStage + 1 if no conditions match or none are defined.
func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData json.RawMessage) (int, error) {
if currentStage < 0 || currentStage >= len(stages) {
return currentStage + 1, nil
}
stage := stages[currentStage]
var rules TransitionRulesWithConditions
if len(stage.TransitionRules) > 0 {
_ = json.Unmarshal(stage.TransitionRules, &rules)
}
if len(rules.Conditions) == 0 {
return currentStage + 1, nil
}
var data map[string]any
if len(stageData) > 0 {
_ = json.Unmarshal(stageData, &data)
}
if data == nil {
data = map[string]any{}
}
for _, cond := range rules.Conditions {
if evaluateCondition(cond, data) {
target, err := resolveTarget(stages, cond.TargetStage)
if err != nil {
return 0, fmt.Errorf("condition matched but target invalid: %w", err)
}
return target, nil
}
}
return currentStage + 1, nil
}
// ResolveStageByName finds a stage ordinal by name (case-insensitive).
func ResolveStageByName(stages []models.WorkflowStage, name string) (int, error) {
lower := strings.ToLower(strings.TrimSpace(name))
for _, s := range stages {
if strings.ToLower(s.Name) == lower {
return s.Ordinal, nil
}
}
return 0, fmt.Errorf("stage %q not found", name)
}
// resolveTarget resolves a target_stage string to an ordinal.
// Accepts either a stage name or a numeric ordinal string.
func resolveTarget(stages []models.WorkflowStage, target string) (int, error) {
// Try as stage name first
ordinal, err := ResolveStageByName(stages, target)
if err == nil {
return ordinal, nil
}
// Try as numeric ordinal
var n int
if _, err := fmt.Sscanf(target, "%d", &n); err == nil {
if n >= 0 && n < len(stages) {
return n, nil
}
return 0, fmt.Errorf("ordinal %d out of range (0..%d)", n, len(stages)-1)
}
return 0, fmt.Errorf("cannot resolve target stage %q", target)
}
// evaluateCondition checks if a single condition matches against data.
func evaluateCondition(cond Condition, data map[string]any) bool {
val, exists := data[cond.Field]
switch cond.Op {
case "exists":
return exists
case "not_exists":
return !exists
}
if !exists {
return false
}
switch cond.Op {
case "eq":
return compareEq(val, cond.Value)
case "neq":
return !compareEq(val, cond.Value)
case "gt":
return compareNum(val, cond.Value) > 0
case "lt":
return compareNum(val, cond.Value) < 0
case "gte":
return compareNum(val, cond.Value) >= 0
case "lte":
return compareNum(val, cond.Value) <= 0
case "in":
return compareIn(val, cond.Value)
case "contains":
return compareContains(val, cond.Value)
default:
return false
}
}
// compareEq does loose equality: normalizes both sides to string for comparison.
func compareEq(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
// compareNum extracts float64 from both sides and returns -1, 0, or 1.
// Returns 0 if either side is not numeric.
func compareNum(a, b any) int {
af := toFloat(a)
bf := toFloat(b)
if af == nil || bf == nil {
return 0
}
switch {
case *af < *bf:
return -1
case *af > *bf:
return 1
default:
return 0
}
}
func toFloat(v any) *float64 {
switch n := v.(type) {
case float64:
return &n
case int:
f := float64(n)
return &f
case json.Number:
if f, err := n.Float64(); err == nil {
return &f
}
case string:
var f float64
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
return &f
}
}
return nil
}
// compareIn checks if val is in the list (cond.Value must be []any).
func compareIn(val, list any) bool {
arr, ok := list.([]any)
if !ok {
return false
}
vs := fmt.Sprintf("%v", val)
for _, item := range arr {
if fmt.Sprintf("%v", item) == vs {
return true
}
}
return false
}
// compareContains checks if val (as string) contains the target substring.
func compareContains(val, target any) bool {
s := fmt.Sprintf("%v", val)
t := fmt.Sprintf("%v", target)
return strings.Contains(s, t)
}

View File

@@ -61,6 +61,40 @@
}
.sb-queue-item:hover .btn-small { opacity: 1; }
/* Collapsed sidebar — show icon only */
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
.sidebar.collapsed .sb-queue-label { display: none; }
.sidebar.collapsed .sb-queue-item .btn-small { display: none; }
.sidebar.collapsed .sb-queue-badge { display: none !important; }
/* Unpin button */
.sb-queue-unpin {
opacity: 0; background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 14px; padding: 0 4px; transition: opacity 0.15s;
}
.sb-queue-item:hover .sb-queue-unpin { opacity: 1; }
.sb-queue-unpin:hover { color: var(--text); }
.sidebar.collapsed .sb-queue-unpin { display: none; }
/* Browse workflows button */
.sb-queue-browse {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; cursor: pointer; border-radius: 6px;
font-size: 12px; color: var(--text-3); transition: background 0.15s;
}
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
.sidebar.collapsed .sb-queue-browse .sb-queue-label { display: none; }
.sidebar.collapsed .sb-queue-browse { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
/* Browse dialog */
.wf-browse-dialog { max-height: 300px; overflow-y: auto; }
.wf-browse-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; border-radius: 6px;
font-size: 13px; transition: background 0.15s;
}
.wf-browse-row:hover { background: var(--bg-raised); }
/* ── Team queue panel ────────────────── */
.wf-queue-panel { padding: 4px 0; }

128
src/js/workflow-monitor.js Normal file
View File

@@ -0,0 +1,128 @@
// workflow-monitor.js — v0.35.0 Workflow Monitoring Dashboard
//
// Admin monitoring tab showing active instances, stage funnels,
// SLA status indicators, and stale instance detection.
export function mountMonitorTab(container, { basePath }) {
container.innerHTML = `
<div class="wf-monitor">
<div class="wf-monitor-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="font-size:16px;font-weight:600">Workflow Monitor</h3>
<button id="monRefresh" class="btn btn-sm btn-outline">Refresh</button>
</div>
<div id="monInstances" style="margin-bottom:24px">Loading…</div>
<div id="monStale" style="margin-bottom:24px"></div>
</div>
`;
let refreshTimer = null;
async function loadInstances() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/instances`);
if (!resp.ok) throw new Error(resp.statusText);
const { data } = await resp.json();
renderInstances(data);
} catch (e) {
document.getElementById('monInstances').textContent = 'Failed to load: ' + e.message;
}
}
async function loadStale() {
try {
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/stale`);
if (!resp.ok) return;
const { data } = await resp.json();
renderStale(data);
} catch (_) {}
}
function renderInstances(instances) {
const el = document.getElementById('monInstances');
if (!instances.length) {
el.innerHTML = '<p style="color:var(--text-3)">No active workflow instances.</p>';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
html += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
html += '<th style="padding:8px">Workflow</th><th style="padding:8px">Stage</th>';
html += '<th style="padding:8px">Age</th><th style="padding:8px">SLA</th>';
html += '</tr></thead><tbody>';
for (const inst of instances) {
const age = formatDuration(inst.age_seconds);
const sla = renderSLA(inst);
html += `<tr style="border-bottom:1px solid var(--border)">`;
html += `<td style="padding:8px">${esc(inst.workflow_name)}<br><span style="font-size:11px;color:var(--text-3)">${esc(inst.channel_title)}</span></td>`;
html += `<td style="padding:8px">${esc(inst.stage_name)} <span style="color:var(--text-3)">(${inst.current_stage})</span></td>`;
html += `<td style="padding:8px">${age}</td>`;
html += `<td style="padding:8px">${sla}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
el.innerHTML = html;
}
function renderSLA(inst) {
if (!inst.sla_seconds) return '<span style="color:var(--text-3)">—</span>';
if (inst.sla_breached) {
return '<span style="color:var(--danger,#e74c3c);font-weight:600">BREACHED</span>';
}
if (inst.sla_remaining_seconds != null) {
const remaining = inst.sla_remaining_seconds;
const pct = Math.max(0, remaining / inst.sla_seconds);
const color = pct > 0.5 ? 'var(--success,#2ecc71)' : pct > 0.2 ? 'var(--warning,#f39c12)' : 'var(--danger,#e74c3c)';
return `<span style="color:${color}">${formatDuration(remaining)}</span>`;
}
return '—';
}
function renderStale(instances) {
const el = document.getElementById('monStale');
if (!instances.length) {
el.innerHTML = '';
return;
}
let html = '<h4 style="font-size:14px;color:var(--danger,#e74c3c);margin-bottom:8px">Stale Instances (' + instances.length + ')</h4>';
html += '<ul style="list-style:none;padding:0">';
for (const inst of instances) {
html += `<li style="padding:4px 0;font-size:13px">${esc(inst.workflow_name)}${esc(inst.stage_name)} (${formatDuration(inst.age_seconds)} old)</li>`;
}
html += '</ul>';
el.innerHTML = html;
}
function formatDuration(seconds) {
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Initial load
loadInstances();
loadStale();
// Auto-refresh every 30s
refreshTimer = setInterval(() => {
loadInstances();
loadStale();
}, 30000);
document.getElementById('monRefresh')?.addEventListener('click', () => {
loadInstances();
loadStale();
});
// Return cleanup function
return () => {
if (refreshTimer) clearInterval(refreshTimer);
};
}

View File

@@ -37,6 +37,30 @@
this.refresh();
},
// ── Pinned set ────────────────────────
// Only show workflow channels the user has explicitly opened/pinned.
// Stored in localStorage to persist across sessions.
_getPinned() {
try {
return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
} catch (_) { return []; }
},
_pin(channelId) {
var pinned = this._getPinned();
if (pinned.indexOf(channelId) === -1) {
pinned.push(channelId);
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
}
},
_unpin(channelId) {
var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
this.refresh();
},
// ── Refresh ─────────────────────────
// Called after loadChats() and on assignment changes.
@@ -45,10 +69,16 @@
var badge = document.getElementById('sbQueueBadge');
if (!body) return;
// 1. Workflow channels from App.chats
var workflows = (App.chats || []).filter(function(c) {
var self = this;
var pinned = this._getPinned();
// 1. Workflow channels — only show pinned ones
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var workflows = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) !== -1;
});
// 2. Claimed assignments (async, best-effort)
var assignments = [];
@@ -56,31 +86,32 @@
var resp = await API.listMyAssignments();
assignments = resp.data || [];
this._myAssignments = assignments;
// Auto-pin channels from assignments
assignments.forEach(function(a) {
if (a.channel_id) self._pin(a.channel_id);
});
} catch (e) {
// Non-critical — show channels even if assignments fail
}
var total = workflows.length + assignments.length;
var unpinnedCount = allWorkflows.length - workflows.length;
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
if (total === 0) {
body.innerHTML = '<div class="sb-section-empty">No active workflows</div>';
return;
}
var html = '';
// Workflow channels
// Workflow channels (pinned only)
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon"></span>' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
'<button class="sb-queue-unpin" data-action="WorkflowQueue.unpin" data-args=\'' + JSON.stringify([wf.id]) + '\' title="Remove from sidebar">&times;</button>' +
'</div>';
});
@@ -92,19 +123,97 @@
html += '<div class="sb-queue-item" ' +
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
'<span class="sb-queue-icon">📋</span>' +
'<span class="sb-queue-icon">&#128203;</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'></button>' +
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>&check;</button>' +
'</div>';
});
}
// Browse button — shows when there are unpinned workflows available
if (unpinnedCount > 0 || total === 0) {
html += '<div class="sb-queue-browse" data-action="WorkflowQueue.browse">' +
'<span class="sb-queue-icon">+</span>' +
'<span class="sb-queue-label">Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '</span>' +
'</div>';
}
body.innerHTML = html;
},
// ── Unpin ──────────────────────────
unpin(channelId) {
this._unpin(channelId);
},
// ── Browse dialog ─────────────────────
browse() {
var self = this;
var pinned = this._getPinned();
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
var unpinned = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) === -1;
});
if (unpinned.length === 0) {
UI.toast('No available workflows', 'info');
return;
}
// Remove any existing browse overlay
var existing = document.getElementById('wfBrowseOverlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'wfBrowseOverlay';
overlay.className = 'confirm-overlay';
var rows = '';
unpinned.forEach(function(wf) {
rows += '<div class="wf-browse-row" data-wf-id="' + wf.id + '">' +
'<span class="sb-queue-icon">&#9889;</span>' +
'<span>' + esc(wf.title || 'Workflow') + '</span>' +
'</div>';
});
overlay.innerHTML =
'<div class="confirm-dialog" style="max-width:400px">' +
'<div class="confirm-header">Browse Workflows</div>' +
'<div class="confirm-body" style="padding:0">' +
'<p style="color:var(--text-2);font-size:13px;margin:0;padding:12px 16px 8px">Select a workflow to add to your sidebar:</p>' +
'<div class="wf-browse-dialog">' + rows + '</div>' +
'</div>' +
'<div class="confirm-footer">' +
'<button class="btn-small" data-action="cancel">Cancel</button>' +
'</div>' +
'</div>';
overlay.addEventListener('click', function(e) {
var row = e.target.closest('.wf-browse-row');
if (row) {
var wfId = row.getAttribute('data-wf-id');
overlay.remove();
self._pin(wfId);
self.refresh();
if (typeof selectChannel === 'function') selectChannel(wfId);
return;
}
if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
overlay.remove();
}
});
document.body.appendChild(overlay);
},
// ── Open Assignment ─────────────────
openAssignment(assignmentId, channelId) {
this._pin(channelId);
if (typeof selectChannel === 'function') {
selectChannel(channelId);
}