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 # 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 ## [0.33.0] — 2026-03-19
### Summary ### 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. | | `chat_only` | Default. Persona drives the conversation. |
| `form_only` | UI-rendered form, no LLM. Visitor fills out fields. | | `form_only` | UI-rendered form, no LLM. Visitor fills out fields. |
| `form_chat` | Both form and chat available. Persona assists alongside the form. | | `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 ### List Stages
@@ -138,11 +139,12 @@ POST /workflows/:id/stages
"ordinal": 0, "ordinal": 0,
"persona_id": "uuid|null", "persona_id": "uuid|null",
"assignment_team_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": [...] }, "form_template": { "fields": [...] },
"history_mode": "full|summary|fresh", "history_mode": "full|summary|fresh",
"auto_transition": false, "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 `transition_rules.auto_assign`: `round_robin` assigns to team members
in rotation. Null = unassigned (manual claim). 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. **Auth:** `workflow.create` permission required.
### Typed Form Template (v0.29.3) ### Typed Form Template (v0.29.3)
@@ -172,7 +180,8 @@ in rotation. Null = unassigned (manual claim).
{ "value": "eng", "label": "Engineering" }, { "value": "eng", "label": "Engineering" },
{ "value": "sales", "label": "Sales" } { "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": { "hooks": {
"package_id": "uuid", "package_id": "uuid",
@@ -190,6 +199,41 @@ in rotation. Null = unassigned (manual claim).
- `min`, `max` — numeric bounds for number fields - `min`, `max` — numeric bounds for number fields
- `min_date`, `max_date` — date range bounds (YYYY-MM-DD format) - `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. **Hooks:** optional Starlark hooks via extension packages.
- `validate`: called before form submission is accepted; can return field errors - `validate`: called before form submission is accepted; can return field errors
- `on_submit`: fire-and-forget hook after successful submission - `on_submit`: fire-and-forget hook after successful submission
@@ -243,6 +287,8 @@ Sets ordinals based on array position.
"history_mode": "full", "history_mode": "full",
"auto_transition": false, "auto_transition": false,
"transition_rules": {}, "transition_rules": {},
"surface_pkg_id": "uuid|null",
"sla_seconds": 3600,
"created_at": "..." "created_at": "..."
} }
``` ```
@@ -315,7 +361,8 @@ GET /channels/:id/workflow/status
"current_stage": 1, "current_stage": 1,
"stage_data": { "name": "Jane", "email": "jane@co.com" }, "stage_data": { "name": "Jane", "email": "jane@co.com" },
"status": "active|completed|stale", "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" } } { "data": { "name": "Jane", "email": "jane@co.com" } }
``` ```
Merges `data` into the channel's accumulated stage data and moves to Merges `data` into the channel's accumulated stage data. The next stage
the next stage. If the current stage is the last, marks the workflow is determined by the **conditional routing engine** (v0.35.0):
as `completed` and sets `ai_mode=off`. `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 After a successful transition, the `on_advance` hook fires if configured
if configured. (see below). On completion, triggers `on_complete` chaining and webhook
delivery if configured.
**Auth:** Authenticated. **Auth:** Authenticated.
@@ -412,6 +464,31 @@ Valid values: `unassigned`, `claimed`, `completed`.
**Auth:** Authenticated (team member). **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 ### Assignment Object
```json ```json
@@ -422,6 +499,9 @@ Valid values: `unassigned`, `claimed`, `completed`.
"team_id": "uuid", "team_id": "uuid",
"assigned_to": "uuid|null", "assigned_to": "uuid|null",
"status": "unassigned|claimed|completed", "status": "unassigned|claimed|completed",
"review_comments": [
{ "text": "...", "user_id": "uuid", "created_at": "..." }
],
"created_at": "...", "created_at": "...",
"claimed_at": "...|null", "claimed_at": "...|null",
"completed_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 stage data as initial context. Webhook delivery (if configured) fires
before chaining. 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 ## WebSocket Events
| Event | When | | 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). Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
**Form Rendering Surface:** **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), HTML inputs (text, email, select, date, textarea, checkbox, file),
validates client-side, submits via `/w/:id/form-submit` validates client-side, submits via `/w/:id/form-submit`
- [ ] Progressive form — multi-step within a single stage (fieldsets) - [x] Progressive form — multi-step within a single stage (fieldsets)
- [ ] Conditional fields — show/hide based on previous answers - [x] Conditional fields — show/hide based on previous answers
- [ ] File upload in forms — attach to stage_data via storage API - [x] File upload in forms — attach to stage_data via storage API
- [ ] Branded form page — uses workflow `branding` (colors, logo, tagline) - [x] Branded form page — uses workflow `branding` (colors, logo, tagline)
**Data Pipeline (between-stage processing):** **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 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) 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 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 not opaque JSON blob. Enables structured review views
**Conditional Routing:** **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) `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 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) 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) using reject (iterative data gathering)
**Structured Review:** **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 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) to the next stage (not buried in chat)
- [ ] Side-by-side view — chat history + structured data together - [x] Side-by-side view — chat history + structured data together
- [ ] Bulk review — multiple assignments in a queue with keyboard nav - [x] Bulk review — multiple assignments in a queue with keyboard nav
**Monitoring Dashboard:** **Monitoring Dashboard:**
- [ ] Active instances list — workflow name, current stage, assignee, - [x] Active instances list — workflow name, current stage, assignee,
age, last activity age, last activity
- [ ] Stage funnel — how many instances at each stage, bottleneck detection - [x] Stage funnel — how many instances at each stage, bottleneck detection
- [ ] SLA timers — configurable per-stage, visible in review + dashboard - [x] SLA timers — configurable per-stage, visible in review + dashboard
- [ ] Stale instance alerts — notify team admins when instances age out - [x] Stale instance alerts — notify team admins when instances age out
**Use case validation targets:** **Use case validation targets:**
- Help desk: AI + KB → escalation to human → resolution tracking - 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/editor-package.js',
'crud/dashboard-package.js', 'crud/dashboard-package.js',
'crud/observability.js', 'crud/observability.js',
'crud/workflow-product.js',
// Orchestrator (must come after all crud/*.js) // Orchestrator (must come after all crud/*.js)
'tier-crud.js', 'tier-crud.js',
'tier-authz.js', 'tier-authz.js',

View File

@@ -33,5 +33,6 @@
if (C.surfaces) await C.surfaces(testTag); if (C.surfaces) await C.surfaces(testTag);
if (C.editorPackage) await C.editorPackage(testTag); if (C.editorPackage) await C.editorPackage(testTag);
if (C.dashboardPackage) await C.dashboardPackage(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' workflow_status TEXT DEFAULT 'active'
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
last_activity_at TIMESTAMPTZ DEFAULT NOW(), last_activity_at TIMESTAMPTZ DEFAULT NOW(),
stage_entered_at TIMESTAMPTZ,
-- KB auto-injection (v0.28.0) -- KB auto-injection (v0.28.0)
kb_auto_inject BOOLEAN NOT NULL DEFAULT false, 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_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 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_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; DROP TRIGGER IF EXISTS channels_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE 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.stage_data IS 'Accumulated form data collected across stages';
COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled'; 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.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'; 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, auto_transition BOOLEAN NOT NULL DEFAULT false,
transition_rules JSONB NOT NULL DEFAULT '{}', transition_rules JSONB NOT NULL DEFAULT '{}',
surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL, surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
sla_seconds INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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.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.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.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, assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned' status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed')), CHECK (status IN ('unassigned', 'claimed', 'completed')),
review_comments JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_at TIMESTAMPTZ, claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ completed_at TIMESTAMPTZ

View File

@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS channels (
workflow_status TEXT DEFAULT 'active' workflow_status TEXT DEFAULT 'active'
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
last_activity_at TEXT DEFAULT (datetime('now')), last_activity_at TEXT DEFAULT (datetime('now')),
stage_entered_at TEXT,
kb_auto_inject INTEGER NOT NULL DEFAULT 0, kb_auto_inject INTEGER NOT NULL DEFAULT 0,
project_id TEXT, project_id TEXT,
workspace_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, auto_transition INTEGER NOT NULL DEFAULT 0,
transition_rules TEXT NOT NULL DEFAULT '{}', transition_rules TEXT NOT NULL DEFAULT '{}',
surface_pkg_id TEXT, surface_pkg_id TEXT,
sla_seconds INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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, assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned' status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed')), CHECK (status IN ('unassigned', 'claimed', 'completed')),
review_comments TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
claimed_at TEXT, claimed_at TEXT,
completed_at TEXT completed_at TEXT

View File

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

View File

@@ -6,6 +6,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"time" "time"
@@ -121,3 +122,62 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID}) 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/sandbox"
"chat-switchboard/store" "chat-switchboard/store"
"chat-switchboard/tools" "chat-switchboard/tools"
"chat-switchboard/workflow"
"go.starlark.net/starlark" "go.starlark.net/starlark"
) )
@@ -148,7 +149,7 @@ func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) {
// 9. Auto-advance if form_only + auto_transition // 9. Auto-advance if form_only + auto_transition
if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition { if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition {
nextStage := ws.CurrentStage + 1 nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData))
if nextStage >= len(stages) { if nextStage >= len(stages) {
// Complete // Complete
_ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) _ = 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) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances // Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start) protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -13,8 +13,10 @@ import (
"chat-switchboard/events" "chat-switchboard/events"
"chat-switchboard/models" "chat-switchboard/models"
"chat-switchboard/notifications" "chat-switchboard/notifications"
"chat-switchboard/sandbox"
"chat-switchboard/store" "chat-switchboard/store"
"chat-switchboard/tools" "chat-switchboard/tools"
"chat-switchboard/workflow"
) )
// ── Workflow Instance Handler ─────────────── // ── Workflow Instance Handler ───────────────
@@ -25,10 +27,11 @@ type WorkflowInstanceHandler struct {
stores store.Stores stores store.Stores
hub *events.Hub hub *events.Hub
notifSvc *notifications.Service notifSvc *notifications.Service
runner *sandbox.Runner
} }
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler { func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc} return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner}
} }
// ── Start ─────────────────────────────────── // ── Start ───────────────────────────────────
@@ -158,7 +161,11 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
_ = c.ShouldBindJSON(&body) _ = c.ShouldBindJSON(&body)
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data) 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) { if nextStage >= len(stages) {
// Workflow complete // Workflow complete
@@ -190,6 +197,17 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") 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] nextStageDef := stages[nextStage]
// Bind next persona (skip for form_only — no LLM needed) // 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) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances // Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start) protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -701,7 +701,7 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances (v0.26.2 — runtime lifecycle) // 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.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
@@ -712,6 +712,8 @@ func main() {
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) 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) // Tasks (v0.27.1, permissions v0.27.2)
taskH := handlers.NewTaskHandler(stores) taskH := handlers.NewTaskHandler(stores)
@@ -1063,6 +1065,11 @@ func main() {
teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage) teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages) teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow) 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) // Team tasks — admin CRUD (v0.27.5)
teamTaskH := handlers.NewTaskHandler(stores) teamTaskH := handlers.NewTaskHandler(stores)
@@ -1318,6 +1325,12 @@ func main() {
wfPkgH := handlers.NewWorkflowPackageHandler(stores) wfPkgH := handlers.NewWorkflowPackageHandler(stores)
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) 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) // Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage) admin.GET("/surfaces/:id", pkgAdm.GetPackage)

View File

@@ -62,6 +62,7 @@ type WorkflowStage struct {
AutoTransition bool `json:"auto_transition"` AutoTransition bool `json:"auto_transition"`
TransitionRules json.RawMessage `json:"transition_rules"` TransitionRules json.RawMessage `json:"transition_rules"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
SLASeconds *int `json:"sla_seconds,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -85,11 +86,28 @@ var ValidStageModes = map[string]bool{
// ── Typed Form Template ───────────────────── // ── Typed Form Template ─────────────────────
// TypedFormTemplate is the structured form_template schema (v0.29.3). // TypedFormTemplate is the structured form_template schema (v0.29.3).
// v0.35.0: added Fieldsets for progressive multi-step forms.
type TypedFormTemplate struct { type TypedFormTemplate struct {
Fields []FormField `json:"fields"` Fields []FormField `json:"fields"`
Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups
Hooks *FormHooks `json:"hooks,omitempty"` 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. // FormField is a single field in a typed form template.
type FormField struct { type FormField struct {
Key string `json:"key"` Key string `json:"key"`
@@ -100,6 +118,7 @@ type FormField struct {
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []FormOption `json:"options,omitempty"` Options []FormOption `json:"options,omitempty"`
Validation *FormValidation `json:"validation,omitempty"` Validation *FormValidation `json:"validation,omitempty"`
Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility
} }
// FormOption is a choice for select fields. // 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 { if err := json.Unmarshal(raw, &tpl); err != nil {
return 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 { if len(tpl.Fields) == 0 {
return nil return nil
} }
@@ -155,6 +187,11 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
return &tpl 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. // FieldError is a validation error for a specific form field.
type FieldError struct { type FieldError struct {
Key string `json:"key"` Key string `json:"key"`
@@ -165,6 +202,11 @@ type FieldError struct {
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
var errs []FieldError var errs []FieldError
for _, f := range tpl.Fields { 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] val, present := data[f.Key]
// Required check // 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"}} 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) ─── // ── Workflow Version (immutable snapshot) ───
// WorkflowVersion is an immutable snapshot of a workflow definition // WorkflowVersion is an immutable snapshot of a workflow definition

View File

@@ -589,6 +589,7 @@ type WorkflowPageData struct {
TotalStages int TotalStages int
CurrentStage int CurrentStage int
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode) 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. // 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) // 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 var totalStages, currentStage int
stageMode = "chat_only" // default stageMode = "chat_only" // default
if e.stores.Channels != nil && channelID != "" { if e.stores.Channels != nil && channelID != "" {
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
if wsErr == nil && ws != nil && ws.WorkflowID != nil { if wsErr == nil && ws != nil && ws.WorkflowID != nil {
currentStage = ws.CurrentStage 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 { if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
totalStages = len(stages) totalStages = len(stages)
if currentStage < len(stages) { if currentStage < len(stages) {
@@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
TotalStages: totalStages, TotalStages: totalStages,
CurrentStage: currentStage, CurrentStage: currentStage,
SurfacePkgID: surfacePkgID, 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-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-col { flex: 1; display: flex; flex-direction: column; }
.wf-body-split .wf-chat { flex: 1; } .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> </style>
<script> <script>
(function() { (function() {
@@ -149,7 +175,7 @@
</head> </head>
<body> <body>
<div class="wf-shell"> <div class="wf-shell">
<div class="wf-header"> <div class="wf-header" id="wfHeader">
<h1>{{.Data.ChannelTitle}}</h1> <h1>{{.Data.ChannelTitle}}</h1>
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}} {{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
</div> </div>
@@ -183,7 +209,10 @@
</div> </div>
</div> </div>
{{else if eq .Data.StageMode "review"}} {{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}} {{else}}
<div class="wf-chat" id="chatMessages"></div> <div class="wf-chat" id="chatMessages"></div>
<div class="wf-input"> <div class="wf-input">
@@ -211,6 +240,33 @@
var FORM_TPL = null; var FORM_TPL = null;
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {} 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 ────────────────── // ── Progress dots ──────────────────
(function() { (function() {
var dotsEl = document.getElementById('progressDots'); var dotsEl = document.getElementById('progressDots');
@@ -224,11 +280,18 @@
} }
})(); })();
// ── Form rendering ───────────────── // ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) { var _fieldsetIndex = 0;
var _fieldsetCount = 0;
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
var formArea = document.getElementById('formArea'); 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); renderForm(formArea, FORM_TPL);
} }
}
function renderForm(container, tpl) { function renderForm(container, tpl) {
var html = ''; var html = '';
@@ -238,6 +301,84 @@
} }
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>'; html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
container.innerHTML = html; 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) { function renderField(f) {
@@ -308,12 +449,22 @@
el.textContent = ''; 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 = {}; var data = {};
for (var i = 0; i < FORM_TPL.fields.length; i++) { for (var i = 0; i < allFields.length; i++) {
var f = FORM_TPL.fields[i]; var f = allFields[i];
var el = document.getElementById('ff_' + f.key); var el = document.getElementById('ff_' + f.key);
if (!el) continue; 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') { if (f.type === 'checkbox') {
data[f.key] = el.checked; data[f.key] = el.checked;
} else if (f.type === 'number') { } 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') { if (STAGE_MODE === 'review') {
var reviewArea = document.getElementById('reviewArea'); var dataPanel = document.getElementById('reviewDataPanel');
if (reviewArea) { var actionPanel = document.getElementById('reviewActionPanel');
loadReviewSurface(reviewArea); if (dataPanel && actionPanel) {
loadReviewSurface(dataPanel, actionPanel);
} }
} }
async function loadReviewSurface(container) { async function loadReviewSurface(dataPanel, actionPanel) {
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>'; 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">'; // Left panel: structured data card
html += '<h3 style="margin-bottom:16px">Review</h3>'; var html = '<div style="padding:24px">';
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
try { try {
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', { var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
@@ -500,33 +653,54 @@
}); });
if (resp.ok) { if (resp.ok) {
var status = await resp.json(); var status = await resp.json();
html += '<div style="margin-bottom:16px">'; if (status.stage_data && typeof status.stage_data === 'object') {
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) {
html += '<table style="width:100%;border-collapse:collapse">'; html += '<table style="width:100%;border-collapse:collapse">';
for (var key in status.stage_data) { 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 += '<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-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[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 += '</tr>';
} }
html += '</table>'; html += '</table>';
} else { } else {
html += '<p style="color:var(--text-3)">No data collected yet.</p>'; html += '<p style="color:var(--text-3)">No data collected yet.</p>';
} }
html += '</div>';
} }
} catch(e) { } catch(e) {
html += '<p style="color:var(--danger)">Failed to load review data.</p>'; 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">'; // Right panel: comment input + approve/reject buttons
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>'; var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
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>'; actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
html += '</div></div>'; actHtml += '<div style="margin-bottom:16px">';
container.innerHTML = html; 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() { document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
this.disabled = true; this.disabled = true;
@@ -536,7 +710,7 @@
body: JSON.stringify({ data: {} }), body: JSON.stringify({ data: {} }),
}); });
if (r.ok) { 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); setTimeout(function() { window.location.reload(); }, 1500);
} else { } else {
var err = await r.json().catch(function() { return {}; }); var err = await r.json().catch(function() { return {}; });
@@ -547,7 +721,8 @@
}); });
document.getElementById('reviewRejectBtn').addEventListener('click', async function() { 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; if (!reason) return;
this.disabled = true; this.disabled = true;
try { try {
@@ -556,7 +731,7 @@
body: JSON.stringify({ reason: reason }), body: JSON.stringify({ reason: reason }),
}); });
if (r.ok) { 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); setTimeout(function() { window.location.reload(); }, 1500);
} else { } else {
var err = await r.json().catch(function() { return {}; }); var err = await r.json().catch(function() { return {}; });

View File

@@ -20,6 +20,7 @@ import (
"go.starlark.net/starlarkstruct" "go.starlark.net/starlarkstruct"
"chat-switchboard/store" "chat-switchboard/store"
"chat-switchboard/workflow"
) )
// BuildWorkflowModule creates the "workflow" Starlark module for a package. // 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)), "get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)),
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)), "advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)),
"reject": starlark.NewBuiltin("workflow.reject", workflowReject(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) 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) { if nextStage >= len(stages) {
// Complete the workflow // Complete the workflow
if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil { 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 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 merges a JSON object into the channel's settings column.
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error 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. // 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) ─────────────────────────────── // ── 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 { 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, ` _, err := DB.ExecContext(ctx, `
UPDATE channels UPDATE channels
SET workflow_id = $1, workflow_version = $2, current_stage = 0, SET workflow_id = $1, workflow_version = $2, current_stage = 0,
stage_data = $3, workflow_status = $4, last_activity_at = $5 stage_data = $3, workflow_status = $4, last_activity_at = $5,
WHERE id = $6 stage_entered_at = $6
`, workflowID, version, stageData, status, time.Now().UTC(), channelID) WHERE id = $7
`, workflowID, version, stageData, status, now, now, channelID)
return err return err
} }
@@ -577,10 +579,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT workflow_id, workflow_version, current_stage, SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at last_activity_at, stage_entered_at
FROM channels WHERE id = $1 AND type = 'workflow' FROM channels WHERE id = $1 AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, `, 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 { if err == sql.ErrNoRows {
return nil, nil 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 { func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE channels UPDATE channels
SET current_stage = $1, stage_data = $2, last_activity_at = $3 SET current_stage = $1, stage_data = $2, last_activity_at = $3,
WHERE id = $4 stage_entered_at = $4
`, nextStage, stageData, time.Now().UTC(), channelID) WHERE id = $5
`, nextStage, stageData, now, now, channelID)
return err 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 { func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3 UPDATE channels SET current_stage = $1, last_activity_at = $2,
`, stage, time.Now().UTC(), channelID) stage_entered_at = $3 WHERE id = $4
`, stage, now, now, channelID)
return err return err
} }
@@ -908,6 +914,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
return err 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 ──────────────────────────── // ── CS7b helpers ────────────────────────────
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { 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, ` return DB.QueryRowContext(ctx, `
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, 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) form_template, stage_mode, history_mode, auto_transition, transition_rules,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) surface_pkg_id, sla_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created_at`, RETURNING id, created_at`,
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, st.WorkflowID, 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,
).Scan(&st.ID, &st.CreatedAt) ).Scan(&st.ID, &st.CreatedAt)
} }
@@ -236,7 +238,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules, 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 FROM workflow_stages WHERE workflow_id = $1
ORDER BY ordinal ASC`, workflowID) ORDER BY ordinal ASC`, workflowID)
if err != nil { 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, if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode, &st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
&st.HistoryMode, &st.AutoTransition, &transRules, &st.HistoryMode, &st.AutoTransition, &transRules,
&st.SurfacePkgID, &st.CreatedAt); err != nil { &st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
return nil, err return nil, err
} }
st.FormTemplate = formTpl st.FormTemplate = formTpl
@@ -271,10 +273,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
UPDATE workflow_stages UPDATE workflow_stages
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5, SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9, 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`, WHERE id = $1`,
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, 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 return err
} }
@@ -497,3 +500,34 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
} }
return result, rows.Err() 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) ─────────────────────────────── // ── 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 { 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, ` _, err := DB.ExecContext(ctx, `
UPDATE channels UPDATE channels
SET workflow_id = ?, workflow_version = ?, current_stage = 0, 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 = ? WHERE id = ?
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID) `, workflowID, version, stageData, status, now, now, channelID)
return err return err
} }
@@ -578,10 +580,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT workflow_id, workflow_version, current_stage, SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at last_activity_at, stage_entered_at
FROM channels WHERE id = ? AND type = 'workflow' FROM channels WHERE id = ? AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, `, 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 { if err == sql.ErrNoRows {
return nil, nil 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 { 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, ` _, err := DB.ExecContext(ctx, `
UPDATE channels UPDATE channels
SET current_stage = ?, stage_data = ?, last_activity_at = ? SET current_stage = ?, stage_data = ?, last_activity_at = ?,
stage_entered_at = ?
WHERE id = ? WHERE id = ?
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID) `, nextStage, stageData, now, now, channelID)
return err 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 { func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ? UPDATE channels SET current_stage = ?, last_activity_at = ?,
`, stage, time.Now().UTC().Format(timeFmt), channelID) stage_entered_at = ? WHERE id = ?
`, stage, now, now, channelID)
return err return err
} }
@@ -866,6 +872,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
return err 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 ──────────────────────────── // ── CS7b helpers ────────────────────────────
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { 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, ` _, err := DB.ExecContext(ctx, `
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules, form_template, stage_mode, history_mode, auto_transition, transition_rules,
surface_pkg_id, created_at) surface_pkg_id, sla_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, 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 return err
} }
@@ -172,7 +172,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id, SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
form_template, stage_mode, history_mode, auto_transition, transition_rules, 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 = ? FROM workflow_stages WHERE workflow_id = ?
ORDER BY ordinal ASC`, workflowID) ORDER BY ordinal ASC`, workflowID)
if err != nil { 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, if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode, &stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
&stg.HistoryMode, &autoTrans, &transRules, &stg.HistoryMode, &autoTrans, &transRules,
&stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil { &stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
return nil, err return nil, err
} }
stg.FormTemplate = json.RawMessage(formTpl) stg.FormTemplate = json.RawMessage(formTpl)
@@ -209,11 +209,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
UPDATE workflow_stages UPDATE workflow_stages
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?, SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?, form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?,
transition_rules = ?, surface_pkg_id = ? transition_rules = ?, surface_pkg_id = ?, sla_seconds = ?
WHERE id = ?`, WHERE id = ?`,
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules, formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
st.SurfacePkgID, st.ID) st.SurfacePkgID, st.SLASeconds, st.ID)
return err return err
} }
@@ -504,3 +504,44 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
} }
return result, rows.Err() 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. // TryRoundRobin finds the least-recently-assigned team member and claims.
// Returns the assigned user ID, or "" if no members available. // Returns the assigned user ID, or "" if no members available.
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) 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"` StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"` Status string `json:"status"`
LastActivityAt *string `json:"last_activity_at"` LastActivityAt *string `json:"last_activity_at"`
StageEnteredAt *string `json:"stage_entered_at,omitempty"`
} }
// WorkflowAssignment is a row from the workflow_assignments table. // WorkflowAssignment is a row from the workflow_assignments table.
@@ -23,6 +24,7 @@ type WorkflowAssignment struct {
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to"` AssignedTo *string `json:"assigned_to"`
Status string `json:"status"` Status string `json:"status"`
ReviewComments json.RawMessage `json:"review_comments"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ClaimedAt *time.Time `json:"claimed_at"` ClaimedAt *time.Time `json:"claimed_at"`
CompletedAt *time.Time `json:"completed_at"` CompletedAt *time.Time `json:"completed_at"`

View File

@@ -10,6 +10,7 @@ import (
"chat-switchboard/models" "chat-switchboard/models"
"chat-switchboard/store" "chat-switchboard/store"
"chat-switchboard/webhook" "chat-switchboard/webhook"
"chat-switchboard/workflow"
) )
// ── Late Registration ──────────────────────── // ── Late Registration ────────────────────────
@@ -22,7 +23,8 @@ func RegisterWorkflowTools(stores store.Stores) {
return return
} }
Register(&workflowAdvanceTool{stores: stores}) 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 // Merge collected data into stage_data
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.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) { if nextStage >= len(stages) {
// Workflow complete // Workflow complete
@@ -151,6 +156,117 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
return string(result), nil 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) ─ // ── Shared helpers (used by both tool and handler) ─
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string. // 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; } .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 ────────────────── */ /* ── Team queue panel ────────────────── */
.wf-queue-panel { padding: 4px 0; } .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(); 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 ───────────────────────── // ── Refresh ─────────────────────────
// Called after loadChats() and on assignment changes. // Called after loadChats() and on assignment changes.
@@ -45,10 +69,16 @@
var badge = document.getElementById('sbQueueBadge'); var badge = document.getElementById('sbQueueBadge');
if (!body) return; if (!body) return;
// 1. Workflow channels from App.chats var self = this;
var workflows = (App.chats || []).filter(function(c) { var pinned = this._getPinned();
// 1. Workflow channels — only show pinned ones
var allWorkflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow'; return c.type === 'workflow';
}); });
var workflows = allWorkflows.filter(function(c) {
return pinned.indexOf(c.id) !== -1;
});
// 2. Claimed assignments (async, best-effort) // 2. Claimed assignments (async, best-effort)
var assignments = []; var assignments = [];
@@ -56,31 +86,32 @@
var resp = await API.listMyAssignments(); var resp = await API.listMyAssignments();
assignments = resp.data || []; assignments = resp.data || [];
this._myAssignments = assignments; this._myAssignments = assignments;
// Auto-pin channels from assignments
assignments.forEach(function(a) {
if (a.channel_id) self._pin(a.channel_id);
});
} catch (e) { } catch (e) {
// Non-critical — show channels even if assignments fail // Non-critical — show channels even if assignments fail
} }
var total = workflows.length + assignments.length; var total = workflows.length + assignments.length;
var unpinnedCount = allWorkflows.length - workflows.length;
if (badge) { if (badge) {
badge.textContent = total; badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none'; badge.style.display = total > 0 ? '' : 'none';
} }
if (total === 0) {
body.innerHTML = '<div class="sb-section-empty">No active workflows</div>';
return;
}
var html = ''; var html = '';
// Workflow channels // Workflow channels (pinned only)
workflows.forEach(function(wf) { workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id; var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' + html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' + 'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
'title="' + esc(wf.title) + '">' + '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>' + '<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>'; '</div>';
}); });
@@ -92,19 +123,97 @@
html += '<div class="sb-queue-item" ' + html += '<div class="sb-queue-item" ' +
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' + '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>' + '<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>'; '</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; 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 ───────────────── // ── Open Assignment ─────────────────
openAssignment(assignmentId, channelId) { openAssignment(assignmentId, channelId) {
this._pin(channelId);
if (typeof selectChannel === 'function') { if (typeof selectChannel === 'function') {
selectChannel(channelId); selectChannel(channelId);
} }