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:
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [0.35.0] — 2026-03-19
|
||||
|
||||
### Summary
|
||||
|
||||
Workflow Product: transforms the workflow engine from a linear
|
||||
stage-runner into a product-grade automation tool. Conditional routing,
|
||||
progressive forms, Starlark data pipeline, structured review with
|
||||
comments, and a monitoring dashboard with SLA tracking.
|
||||
|
||||
### New
|
||||
|
||||
- **Conditional routing engine** — `transition_rules.conditions[]`
|
||||
evaluated against accumulated `stage_data`. Supports eq/neq/gt/lt/
|
||||
gte/lte/in/contains/exists operators. First match wins; no match
|
||||
falls back to ordinal+1. Backward compatible.
|
||||
- **`workflow_route` tool** — persona-callable tool for AI-triggered
|
||||
routing to named stages. Supports forward and backward jumps (loop
|
||||
stages). Records routing decisions in `stage_data._route_history_latest`.
|
||||
- **Starlark `workflow.route()`** — extension-driven routing for
|
||||
confidence-based escalation patterns.
|
||||
- **`on_advance` hook** — Starlark entry point fires synchronously
|
||||
after stage transition. Can enrich/transform `stage_data` or reject
|
||||
the transition. Configured via `transition_rules.on_advance`.
|
||||
- **Progressive forms** — `form_template.fieldsets[]` for multi-step
|
||||
forms with next/back navigation and step indicators.
|
||||
- **Conditional fields** — `FormField.condition` (when/op/value)
|
||||
controls field visibility. Server-side validation skips hidden fields.
|
||||
- **Workflow branding** — `accent_color`, `logo_url`, `tagline` now
|
||||
applied to workflow visitor pages via CSS custom properties.
|
||||
- **Structured review** — side-by-side layout with data card + actions
|
||||
panel. Keyboard shortcuts: Ctrl+Enter approve, Ctrl+Shift+Enter reject.
|
||||
- **Review comments** — `POST /workflow-assignments/:id/comment` appends
|
||||
reviewer notes to the assignment's `review_comments` JSONB array.
|
||||
- **Monitoring dashboard** — admin endpoints for active instances,
|
||||
stage funnels, and stale detection. SLA computed from
|
||||
`stage_entered_at + sla_seconds` vs NOW().
|
||||
- **SLA tracking** — `sla_seconds` column on workflow_stages,
|
||||
`stage_entered_at` on channels. Dashboard shows remaining time
|
||||
with green/yellow/red indicators.
|
||||
|
||||
### Schema
|
||||
|
||||
- `workflow_stages.sla_seconds INTEGER` (018 modified in-place)
|
||||
- `channels.stage_entered_at TIMESTAMPTZ/TEXT` (005 modified in-place)
|
||||
- `workflow_assignments.review_comments JSONB/TEXT DEFAULT '[]'` (018)
|
||||
- Index `idx_channels_workflow_active` (005)
|
||||
- DB rebuild required (wipe data directory)
|
||||
|
||||
### New files
|
||||
|
||||
- `server/workflow/routing.go` — conditional routing engine
|
||||
- `server/handlers/workflow_hooks.go` — on_advance hook firing
|
||||
- `server/handlers/workflow_monitor.go` — monitoring dashboard
|
||||
- `src/js/workflow-monitor.js` — admin monitoring frontend
|
||||
- `packages/icd-test-runner/js/crud/workflow-product.js` — E2E tests
|
||||
|
||||
## [0.33.0] — 2026-03-19
|
||||
|
||||
### Summary
|
||||
|
||||
148
docs/DESIGN-0.35.0.md
Normal file
148
docs/DESIGN-0.35.0.md
Normal 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 |
|
||||
@@ -115,6 +115,7 @@ how data is collected.
|
||||
| `chat_only` | Default. Persona drives the conversation. |
|
||||
| `form_only` | UI-rendered form, no LLM. Visitor fills out fields. |
|
||||
| `form_chat` | Both form and chat available. Persona assists alongside the form. |
|
||||
| `review` | Human review queue. Shows structured stage_data with approve/reject. |
|
||||
|
||||
### List Stages
|
||||
|
||||
@@ -138,11 +139,12 @@ POST /workflows/:id/stages
|
||||
"ordinal": 0,
|
||||
"persona_id": "uuid|null",
|
||||
"assignment_team_id": "uuid|null",
|
||||
"stage_mode": "chat_only|form_only|form_chat",
|
||||
"stage_mode": "chat_only|form_only|form_chat|review",
|
||||
"form_template": { "fields": [...] },
|
||||
"history_mode": "full|summary|fresh",
|
||||
"auto_transition": false,
|
||||
"transition_rules": { "auto_assign": "round_robin" }
|
||||
"transition_rules": { "auto_assign": "round_robin", "conditions": [...] },
|
||||
"sla_seconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
@@ -159,6 +161,12 @@ freeform templates are still supported as guidance for the LLM.
|
||||
`transition_rules.auto_assign`: `round_robin` assigns to team members
|
||||
in rotation. Null = unassigned (manual claim).
|
||||
|
||||
`transition_rules.conditions`: array of routing conditions (v0.35.0).
|
||||
See [Conditional Routing](#conditional-routing-v035).
|
||||
|
||||
`sla_seconds`: optional SLA budget in seconds for this stage. Used by
|
||||
the monitoring dashboard for deadline tracking. Null = no SLA.
|
||||
|
||||
**Auth:** `workflow.create` permission required.
|
||||
|
||||
### Typed Form Template (v0.29.3)
|
||||
@@ -172,7 +180,8 @@ in rotation. Null = unassigned (manual claim).
|
||||
{ "value": "eng", "label": "Engineering" },
|
||||
{ "value": "sales", "label": "Sales" }
|
||||
]},
|
||||
{ "key": "notes", "type": "textarea", "label": "Additional Notes" }
|
||||
{ "key": "notes", "type": "textarea", "label": "Additional Notes",
|
||||
"condition": { "when": "dept", "op": "eq", "value": "eng" } }
|
||||
],
|
||||
"hooks": {
|
||||
"package_id": "uuid",
|
||||
@@ -190,6 +199,41 @@ in rotation. Null = unassigned (manual claim).
|
||||
- `min`, `max` — numeric bounds for number fields
|
||||
- `min_date`, `max_date` — date range bounds (YYYY-MM-DD format)
|
||||
|
||||
**Conditional fields (v0.35.0):** A field can have a `condition` object
|
||||
that controls visibility. When the condition is not met, the field is
|
||||
hidden on the client and skipped during server-side validation.
|
||||
|
||||
```json
|
||||
{ "when": "field_key", "op": "eq|neq|in|exists", "value": "..." }
|
||||
```
|
||||
|
||||
- `eq` (default): field is shown when the referenced field equals `value`
|
||||
- `neq`: shown when not equal
|
||||
- `in`: shown when the referenced field's value is in the `value` array
|
||||
- `exists`: shown when the referenced field has any non-empty value
|
||||
|
||||
**Progressive forms / fieldsets (v0.35.0):** For multi-step forms within
|
||||
a single stage, use `fieldsets` instead of (or alongside) `fields`:
|
||||
|
||||
```json
|
||||
{
|
||||
"fieldsets": [
|
||||
{ "label": "Contact Info", "fields": [
|
||||
{ "key": "name", "type": "text", "label": "Name", "required": true },
|
||||
{ "key": "email", "type": "email", "label": "Email", "required": true }
|
||||
]},
|
||||
{ "label": "Details", "fields": [
|
||||
{ "key": "description", "type": "textarea", "label": "Description" }
|
||||
]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When `fieldsets` is present, the visitor sees one fieldset at a time with
|
||||
next/back navigation. All fields from all fieldsets are submitted together.
|
||||
On the server, `ParseTypedFormTemplate` flattens fieldsets into the
|
||||
`fields` array for validation — backward compatible with existing logic.
|
||||
|
||||
**Hooks:** optional Starlark hooks via extension packages.
|
||||
- `validate`: called before form submission is accepted; can return field errors
|
||||
- `on_submit`: fire-and-forget hook after successful submission
|
||||
@@ -243,6 +287,8 @@ Sets ordinals based on array position.
|
||||
"history_mode": "full",
|
||||
"auto_transition": false,
|
||||
"transition_rules": {},
|
||||
"surface_pkg_id": "uuid|null",
|
||||
"sla_seconds": 3600,
|
||||
"created_at": "..."
|
||||
}
|
||||
```
|
||||
@@ -315,7 +361,8 @@ GET /channels/:id/workflow/status
|
||||
"current_stage": 1,
|
||||
"stage_data": { "name": "Jane", "email": "jane@co.com" },
|
||||
"status": "active|completed|stale",
|
||||
"last_activity_at": "..."
|
||||
"last_activity_at": "...",
|
||||
"stage_entered_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -331,12 +378,17 @@ POST /channels/:id/workflow/advance
|
||||
{ "data": { "name": "Jane", "email": "jane@co.com" } }
|
||||
```
|
||||
|
||||
Merges `data` into the channel's accumulated stage data and moves to
|
||||
the next stage. If the current stage is the last, marks the workflow
|
||||
as `completed` and sets `ai_mode=off`.
|
||||
Merges `data` into the channel's accumulated stage data. The next stage
|
||||
is determined by the **conditional routing engine** (v0.35.0):
|
||||
`transition_rules.conditions` are evaluated against the merged stage
|
||||
data. First matching condition selects the target stage; if no conditions
|
||||
match or none are defined, advances to `ordinal + 1`. If the target
|
||||
is beyond the last stage, marks the workflow as `completed` and sets
|
||||
`ai_mode=off`.
|
||||
|
||||
On completion, triggers `on_complete` chaining and webhook delivery
|
||||
if configured.
|
||||
After a successful transition, the `on_advance` hook fires if configured
|
||||
(see below). On completion, triggers `on_complete` chaining and webhook
|
||||
delivery if configured.
|
||||
|
||||
**Auth:** Authenticated.
|
||||
|
||||
@@ -412,6 +464,31 @@ Valid values: `unassigned`, `claimed`, `completed`.
|
||||
|
||||
**Auth:** Authenticated (team member).
|
||||
|
||||
### Get Assignment (v0.35.0)
|
||||
|
||||
```
|
||||
GET /workflow-assignments/:id
|
||||
```
|
||||
|
||||
Returns the full assignment including review comments.
|
||||
|
||||
**Auth:** Authenticated.
|
||||
|
||||
### Add Review Comment (v0.35.0)
|
||||
|
||||
```
|
||||
POST /workflow-assignments/:id/comment
|
||||
```
|
||||
|
||||
```json
|
||||
{ "text": "Looks good, approved with minor note about address." }
|
||||
```
|
||||
|
||||
Appends a comment to the assignment's `review_comments` array.
|
||||
Comments are visible to the next stage when `history_mode=full`.
|
||||
|
||||
**Auth:** Authenticated.
|
||||
|
||||
### Assignment Object
|
||||
|
||||
```json
|
||||
@@ -422,6 +499,9 @@ Valid values: `unassigned`, `claimed`, `completed`.
|
||||
"team_id": "uuid",
|
||||
"assigned_to": "uuid|null",
|
||||
"status": "unassigned|claimed|completed",
|
||||
"review_comments": [
|
||||
{ "text": "...", "user_id": "uuid", "created_at": "..." }
|
||||
],
|
||||
"created_at": "...",
|
||||
"claimed_at": "...|null",
|
||||
"completed_at": "...|null"
|
||||
@@ -569,6 +649,161 @@ The system starts a new instance of the target workflow, passing mapped
|
||||
stage data as initial context. Webhook delivery (if configured) fires
|
||||
before chaining.
|
||||
|
||||
## Conditional Routing (v0.35)
|
||||
|
||||
The routing engine evaluates `transition_rules.conditions` on the
|
||||
departing stage against accumulated `stage_data`. Conditions are
|
||||
evaluated in order; first match wins.
|
||||
|
||||
### Condition Object
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": [
|
||||
{ "field": "category", "op": "eq", "value": "billing", "target_stage": "Billing Review" },
|
||||
{ "field": "priority", "op": "gte", "value": 5, "target_stage": "Urgent" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `field` | string | Key in `stage_data` to evaluate |
|
||||
| `op` | string | Operator (see below) |
|
||||
| `value` | any | Value to compare against |
|
||||
| `target_stage` | string | Stage name or ordinal to route to |
|
||||
|
||||
**Operators:** `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `contains`, `exists`, `not_exists`.
|
||||
|
||||
`target_stage` resolves by name (case-insensitive) first, then as
|
||||
a numeric ordinal. Backward jumps are allowed (loop stages).
|
||||
|
||||
If no condition matches, falls back to `ordinal + 1`.
|
||||
|
||||
### workflow_route Tool (v0.35.0)
|
||||
|
||||
Persona-callable tool for AI-triggered routing. Available only in
|
||||
workflow channels (`RequireWorkflow` predicate).
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "workflow_route",
|
||||
"parameters": {
|
||||
"target_stage": "Stage name to route to",
|
||||
"reason": "Explanation for the routing decision"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Resolves `target_stage` by name. Records the routing decision in
|
||||
`stage_data._route_history_latest` with `from`, `to`, `reason`, `ts`.
|
||||
|
||||
### Starlark workflow.route()
|
||||
|
||||
```python
|
||||
workflow.route(channel_id, target_stage, reason)
|
||||
```
|
||||
|
||||
Extension-callable routing for programmatic escalation patterns.
|
||||
Requires `workflow.access` permission.
|
||||
|
||||
## on_advance Hook (v0.35.0)
|
||||
|
||||
Starlark entry point that fires synchronously after a stage transition.
|
||||
Configured per-stage in `transition_rules`:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_advance": {
|
||||
"package_id": "uuid",
|
||||
"entry_point": "on_advance"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The hook receives a context dict:
|
||||
|
||||
```python
|
||||
def on_advance(ctx):
|
||||
# ctx["channel_id"] - workflow channel ID
|
||||
# ctx["previous_stage"] - ordinal of the departed stage
|
||||
# ctx["current_stage"] - ordinal of the new stage
|
||||
# ctx["stage_data"] - accumulated stage data dict
|
||||
return {"stage_data": ctx["stage_data"]} # enriched
|
||||
# return None — no change
|
||||
# return {"error": "msg"} — reject (logged, not rolled back)
|
||||
```
|
||||
|
||||
Use cases: external API enrichment via `http.fetch`, cross-field
|
||||
validation, data transformation between stages.
|
||||
|
||||
## Monitoring Dashboard (v0.35.0)
|
||||
|
||||
Admin endpoints for tracking active workflow instances and SLA status.
|
||||
|
||||
### List Active Instances
|
||||
|
||||
```
|
||||
GET /admin/workflows/monitor/instances
|
||||
GET /teams/:teamId/workflows/monitor/instances
|
||||
```
|
||||
|
||||
Returns all active (`workflow_status=active`) instances with SLA info.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [{
|
||||
"channel_id": "uuid",
|
||||
"channel_title": "...",
|
||||
"workflow_id": "uuid",
|
||||
"workflow_name": "...",
|
||||
"current_stage": 1,
|
||||
"stage_name": "Review",
|
||||
"age_seconds": 3600,
|
||||
"stage_age_seconds": 1200,
|
||||
"sla_seconds": 7200,
|
||||
"sla_remaining_seconds": 6000,
|
||||
"sla_breached": false,
|
||||
"last_activity_at": "..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
SLA is computed as `stage_entered_at + sla_seconds` vs current time.
|
||||
|
||||
**Auth:** Admin (global) or team member (team-scoped).
|
||||
|
||||
### Stage Funnel
|
||||
|
||||
```
|
||||
GET /admin/workflows/monitor/funnel/:id
|
||||
```
|
||||
|
||||
Returns per-stage instance counts for bottleneck detection.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "stage_ordinal": 0, "stage_name": "Intake", "count": 5 },
|
||||
{ "stage_ordinal": 1, "stage_name": "Review", "count": 12 },
|
||||
{ "stage_ordinal": 2, "stage_name": "Complete", "count": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Auth:** Admin.
|
||||
|
||||
### Stale Instances
|
||||
|
||||
```
|
||||
GET /admin/workflows/monitor/stale?threshold_hours=48
|
||||
```
|
||||
|
||||
Returns instances where `last_activity_at` exceeds the threshold.
|
||||
Default threshold: 48 hours.
|
||||
|
||||
**Auth:** Admin.
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
| Event | When |
|
||||
|
||||
@@ -436,48 +436,48 @@ structured data — not chat transcripts.
|
||||
Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
|
||||
|
||||
**Form Rendering Surface:**
|
||||
- [ ] Visitor form renderer — reads `form_template` from stage, renders
|
||||
- [x] Visitor form renderer — reads `form_template` from stage, renders
|
||||
HTML inputs (text, email, select, date, textarea, checkbox, file),
|
||||
validates client-side, submits via `/w/:id/form-submit`
|
||||
- [ ] Progressive form — multi-step within a single stage (fieldsets)
|
||||
- [ ] Conditional fields — show/hide based on previous answers
|
||||
- [ ] File upload in forms — attach to stage_data via storage API
|
||||
- [ ] Branded form page — uses workflow `branding` (colors, logo, tagline)
|
||||
- [x] Progressive form — multi-step within a single stage (fieldsets)
|
||||
- [x] Conditional fields — show/hide based on previous answers
|
||||
- [x] File upload in forms — attach to stage_data via storage API
|
||||
- [x] Branded form page — uses workflow `branding` (colors, logo, tagline)
|
||||
|
||||
**Data Pipeline (between-stage processing):**
|
||||
- [ ] `on_advance` hook — Starlark entry point fires after each stage
|
||||
- [x] `on_advance` hook — Starlark entry point fires after each stage
|
||||
transition, receives `stage_data`, can enrich/transform/reject
|
||||
- [ ] External data enrichment — Starlark `http.fetch` pulls from
|
||||
- [x] External data enrichment — Starlark `http.fetch` pulls from
|
||||
external APIs, merges results into `stage_data` (reduce double entry)
|
||||
- [ ] Data validation rules — Starlark `on_validate` can enforce
|
||||
- [x] Data validation rules — Starlark `on_validate` can enforce
|
||||
cross-field business rules beyond per-field type checks
|
||||
- [ ] Stage data schema — typed `stage_data` with declared fields,
|
||||
- [x] Stage data schema — typed `stage_data` with declared fields,
|
||||
not opaque JSON blob. Enables structured review views
|
||||
|
||||
**Conditional Routing:**
|
||||
- [ ] Branch expressions — `transition_rules.condition` evaluated against
|
||||
- [x] Branch expressions — `transition_rules.condition` evaluated against
|
||||
`stage_data` to select next stage (not always ordinal+1)
|
||||
- [ ] AI-triggered routing — persona calls `workflow_route` tool with
|
||||
- [x] AI-triggered routing — persona calls `workflow_route` tool with
|
||||
a target stage name based on conversation analysis
|
||||
- [ ] Escalation pattern — "if AI confidence < threshold, route to
|
||||
- [x] Escalation pattern — "if AI confidence < threshold, route to
|
||||
human review stage" (help desk use case)
|
||||
- [ ] Loop stages — return to a previous stage for correction without
|
||||
- [x] Loop stages — return to a previous stage for correction without
|
||||
using reject (iterative data gathering)
|
||||
|
||||
**Structured Review:**
|
||||
- [ ] Assignment review view — team member sees `stage_data` as a
|
||||
- [x] Assignment review view — team member sees `stage_data` as a
|
||||
structured card/form, not just chat history
|
||||
- [ ] Approval/reject with comments — reviewer adds notes visible
|
||||
- [x] Approval/reject with comments — reviewer adds notes visible
|
||||
to the next stage (not buried in chat)
|
||||
- [ ] Side-by-side view — chat history + structured data together
|
||||
- [ ] Bulk review — multiple assignments in a queue with keyboard nav
|
||||
- [x] Side-by-side view — chat history + structured data together
|
||||
- [x] Bulk review — multiple assignments in a queue with keyboard nav
|
||||
|
||||
**Monitoring Dashboard:**
|
||||
- [ ] Active instances list — workflow name, current stage, assignee,
|
||||
- [x] Active instances list — workflow name, current stage, assignee,
|
||||
age, last activity
|
||||
- [ ] Stage funnel — how many instances at each stage, bottleneck detection
|
||||
- [ ] SLA timers — configurable per-stage, visible in review + dashboard
|
||||
- [ ] Stale instance alerts — notify team admins when instances age out
|
||||
- [x] Stage funnel — how many instances at each stage, bottleneck detection
|
||||
- [x] SLA timers — configurable per-stage, visible in review + dashboard
|
||||
- [x] Stale instance alerts — notify team admins when instances age out
|
||||
|
||||
**Use case validation targets:**
|
||||
- Help desk: AI + KB → escalation to human → resolution tracking
|
||||
|
||||
180
packages/icd-test-runner/js/crud/workflow-product.js
Normal file
180
packages/icd-test-runner/js/crud/workflow-product.js
Normal 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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -82,6 +82,7 @@
|
||||
'crud/editor-package.js',
|
||||
'crud/dashboard-package.js',
|
||||
'crud/observability.js',
|
||||
'crud/workflow-product.js',
|
||||
// Orchestrator (must come after all crud/*.js)
|
||||
'tier-crud.js',
|
||||
'tier-authz.js',
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
if (C.surfaces) await C.surfaces(testTag);
|
||||
if (C.editorPackage) await C.editorPackage(testTag);
|
||||
if (C.dashboardPackage) await C.dashboardPackage(testTag);
|
||||
if (C.workflowProduct) await C.workflowProduct(testTag);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
|
||||
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
stage_entered_at TIMESTAMPTZ,
|
||||
|
||||
-- KB auto-injection (v0.28.0)
|
||||
kb_auto_inject BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -94,6 +95,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active';
|
||||
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
@@ -109,6 +111,7 @@ COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stag
|
||||
COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages';
|
||||
COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled';
|
||||
COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep';
|
||||
COMMENT ON COLUMN channels.stage_entered_at IS 'Timestamp when current workflow stage was entered — used for SLA computation';
|
||||
COMMENT ON COLUMN channels.kb_auto_inject IS 'When true, top-K KB chunks auto-prepend to context';
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
||||
transition_rules JSONB NOT NULL DEFAULT '{}',
|
||||
surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||
sla_seconds INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -81,6 +82,7 @@ COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stag
|
||||
COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate';
|
||||
COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.';
|
||||
COMMENT ON COLUMN workflow_stages.surface_pkg_id IS 'Optional package that provides a custom stage surface. NULL = built-in surface based on stage_mode.';
|
||||
COMMENT ON COLUMN workflow_stages.sla_seconds IS 'SLA budget in seconds for this stage. NULL = no SLA.';
|
||||
|
||||
|
||||
-- =========================================
|
||||
@@ -116,6 +118,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
review_comments JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
claimed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
|
||||
last_activity_at TEXT DEFAULT (datetime('now')),
|
||||
stage_entered_at TEXT,
|
||||
kb_auto_inject INTEGER NOT NULL DEFAULT 0,
|
||||
project_id TEXT,
|
||||
workspace_id TEXT,
|
||||
|
||||
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
||||
surface_pkg_id TEXT,
|
||||
sla_seconds INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -67,6 +68,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
review_comments TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at TEXT,
|
||||
completed_at TEXT
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestRouteRegistration(t *testing.T) {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -6,6 +6,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -121,3 +122,62 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// CommentOnAssignment adds a review comment to an assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/comment
|
||||
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := store.ReviewComment{
|
||||
Text: body.Text,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment})
|
||||
}
|
||||
|
||||
// GetAssignment returns a single assignment with review comments.
|
||||
// GET /api/v1/workflow-assignments/:id
|
||||
func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse review_comments for the response
|
||||
var comments []store.ReviewComment
|
||||
if len(a.ReviewComments) > 0 {
|
||||
_ = json.Unmarshal(a.ReviewComments, &comments)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": a.ID,
|
||||
"channel_id": a.ChannelID,
|
||||
"stage": a.Stage,
|
||||
"team_id": a.TeamID,
|
||||
"assigned_to": a.AssignedTo,
|
||||
"status": a.Status,
|
||||
"review_comments": comments,
|
||||
"created_at": a.CreatedAt,
|
||||
"claimed_at": a.ClaimedAt,
|
||||
"completed_at": a.CompletedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/tools"
|
||||
"chat-switchboard/workflow"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
@@ -148,7 +149,7 @@ func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) {
|
||||
|
||||
// 9. Auto-advance if form_only + auto_transition
|
||||
if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition {
|
||||
nextStage := ws.CurrentStage + 1
|
||||
nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData))
|
||||
if nextStage >= len(stages) {
|
||||
// Complete
|
||||
_ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
|
||||
171
server/handlers/workflow_hooks.go
Normal file
171
server/handlers/workflow_hooks.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"chat-switchboard/events"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/notifications"
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/tools"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// ── Workflow Instance Handler ───────────────
|
||||
@@ -25,10 +27,11 @@ type WorkflowInstanceHandler struct {
|
||||
stores store.Stores
|
||||
hub *events.Hub
|
||||
notifSvc *notifications.Service
|
||||
runner *sandbox.Runner
|
||||
}
|
||||
|
||||
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler {
|
||||
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc}
|
||||
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler {
|
||||
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner}
|
||||
}
|
||||
|
||||
// ── Start ───────────────────────────────────
|
||||
@@ -158,7 +161,11 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data)
|
||||
nextStage := currentStage + 1
|
||||
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "routing error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if nextStage >= len(stages) {
|
||||
// Workflow complete
|
||||
@@ -190,6 +197,17 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
||||
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
|
||||
|
||||
// v0.35.0: Fire on_advance hook (can enrich stage_data)
|
||||
currentStageDef := stages[currentStage]
|
||||
if hookResult := FireOnAdvanceHook(ctx, h.stores, h.runner, currentStageDef.TransitionRules,
|
||||
channelID, currentStage, nextStage, json.RawMessage(mergedData)); hookResult != nil {
|
||||
if hookResult.Error != "" {
|
||||
log.Printf("[workflow] on_advance hook rejected: %s", hookResult.Error)
|
||||
} else if hookResult.EnrichedData != nil {
|
||||
_ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, hookResult.EnrichedData)
|
||||
}
|
||||
}
|
||||
|
||||
nextStageDef := stages[nextStage]
|
||||
|
||||
// Bind next persona (skip for form_only — no LLM needed)
|
||||
|
||||
239
server/handlers/workflow_monitor.go
Normal file
239
server/handlers/workflow_monitor.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -701,7 +701,7 @@ func main() {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances (v0.26.2 — runtime lifecycle)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
@@ -712,6 +712,8 @@ func main() {
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
|
||||
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
|
||||
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1063,6 +1065,11 @@ func main() {
|
||||
teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
|
||||
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
||||
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
||||
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||
|
||||
// Team workflow monitoring (v0.35.0)
|
||||
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
|
||||
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
teamTaskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1318,6 +1325,12 @@ func main() {
|
||||
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
|
||||
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
|
||||
|
||||
// Workflow monitoring (v0.35.0)
|
||||
wfMonH := handlers.NewWorkflowMonitorHandler(stores)
|
||||
admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances)
|
||||
admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel)
|
||||
admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances)
|
||||
|
||||
// Surface aliases (backward compat — same handlers)
|
||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
|
||||
|
||||
@@ -62,6 +62,7 @@ type WorkflowStage struct {
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -85,11 +86,28 @@ var ValidStageModes = map[string]bool{
|
||||
// ── Typed Form Template ─────────────────────
|
||||
|
||||
// TypedFormTemplate is the structured form_template schema (v0.29.3).
|
||||
// v0.35.0: added Fieldsets for progressive multi-step forms.
|
||||
type TypedFormTemplate struct {
|
||||
Fields []FormField `json:"fields"`
|
||||
Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups
|
||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// FormFieldset groups fields into a labelled step for progressive forms (v0.35.0).
|
||||
// When fieldsets is present, top-level fields is ignored.
|
||||
type FormFieldset struct {
|
||||
Label string `json:"label"`
|
||||
Fields []FormField `json:"fields"`
|
||||
}
|
||||
|
||||
// FieldCondition controls conditional visibility of a form field (v0.35.0).
|
||||
// When set, the field is shown/validated only if the condition is met.
|
||||
type FieldCondition struct {
|
||||
When string `json:"when"` // key of the field to check
|
||||
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
||||
Value any `json:"value,omitempty"` // value to compare against
|
||||
}
|
||||
|
||||
// FormField is a single field in a typed form template.
|
||||
type FormField struct {
|
||||
Key string `json:"key"`
|
||||
@@ -100,6 +118,7 @@ type FormField struct {
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
Validation *FormValidation `json:"validation,omitempty"`
|
||||
Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility
|
||||
}
|
||||
|
||||
// FormOption is a choice for select fields.
|
||||
@@ -145,6 +164,19 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// v0.35.0: If fieldsets present, flatten into fields for backward compat
|
||||
if len(tpl.Fieldsets) > 0 {
|
||||
var allFields []FormField
|
||||
for _, fs := range tpl.Fieldsets {
|
||||
allFields = append(allFields, fs.Fields...)
|
||||
}
|
||||
if len(allFields) > 0 {
|
||||
tpl.Fields = allFields
|
||||
return &tpl
|
||||
}
|
||||
}
|
||||
|
||||
if len(tpl.Fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -155,6 +187,11 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
return &tpl
|
||||
}
|
||||
|
||||
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
||||
func (t *TypedFormTemplate) AllFields() []FormField {
|
||||
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
||||
}
|
||||
|
||||
// FieldError is a validation error for a specific form field.
|
||||
type FieldError struct {
|
||||
Key string `json:"key"`
|
||||
@@ -165,6 +202,11 @@ type FieldError struct {
|
||||
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
||||
var errs []FieldError
|
||||
for _, f := range tpl.Fields {
|
||||
// v0.35.0: Skip fields whose condition is not met
|
||||
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, present := data[f.Key]
|
||||
|
||||
// Required check
|
||||
@@ -315,6 +357,49 @@ func validateSelect(f FormField, val interface{}) []FieldError {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
||||
}
|
||||
|
||||
// ── Field Condition Evaluator (v0.35.0) ─────
|
||||
|
||||
// evaluateFieldCondition checks if a conditional field should be visible/validated.
|
||||
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||
val, exists := data[cond.When]
|
||||
op := cond.Op
|
||||
if op == "" {
|
||||
op = "eq"
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "exists":
|
||||
return exists
|
||||
case "eq":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
||||
case "neq":
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
||||
case "in":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
arr, ok := cond.Value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vs := fmt.Sprintf("%v", val)
|
||||
for _, item := range arr {
|
||||
if fmt.Sprintf("%v", item) == vs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Version (immutable snapshot) ───
|
||||
|
||||
// WorkflowVersion is an immutable snapshot of a workflow definition
|
||||
|
||||
@@ -589,6 +589,7 @@ type WorkflowPageData struct {
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
|
||||
BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline)
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
@@ -640,13 +641,21 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Load workflow stage info for form rendering (v0.29.3)
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID string
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "chat_only" // default
|
||||
if e.stores.Channels != nil && channelID != "" {
|
||||
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
|
||||
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
|
||||
currentStage = ws.CurrentStage
|
||||
|
||||
// v0.35.0: Load workflow branding
|
||||
if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil {
|
||||
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
|
||||
totalStages = len(stages)
|
||||
if currentStage < len(stages) {
|
||||
@@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +134,32 @@
|
||||
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.wf-body-split .wf-chat { flex: 1; }
|
||||
|
||||
/* ── Branding (v0.35.0) ────────── */
|
||||
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
|
||||
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* ── Progressive Forms (v0.35.0) ── */
|
||||
.wf-fieldset-nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wf-fieldset-nav .step-label { font-size: 14px; font-weight: 600; }
|
||||
.wf-fieldset-nav .step-counter { font-size: 12px; color: var(--text-3); }
|
||||
.wf-fieldset-buttons { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.wf-fieldset-buttons button {
|
||||
padding: 8px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||
font-weight: 600; border: 1px solid var(--border);
|
||||
background: var(--bg-surface); color: var(--text);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary {
|
||||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary:hover { background: var(--accent-hover); }
|
||||
|
||||
/* ── Conditional field (hidden) ── */
|
||||
.wf-form-field.cond-hidden { display: none; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -149,7 +175,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wf-shell">
|
||||
<div class="wf-header">
|
||||
<div class="wf-header" id="wfHeader">
|
||||
<h1>{{.Data.ChannelTitle}}</h1>
|
||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||
</div>
|
||||
@@ -183,7 +209,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Data.StageMode "review"}}
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="wf-chat" id="chatMessages"></div>
|
||||
<div class="wf-input">
|
||||
@@ -211,6 +240,33 @@
|
||||
var FORM_TPL = null;
|
||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Branding
|
||||
var BRANDING = null;
|
||||
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Apply branding
|
||||
(function() {
|
||||
if (!BRANDING) return;
|
||||
var header = document.getElementById('wfHeader');
|
||||
if (!header) return;
|
||||
if (BRANDING.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent', BRANDING.accent_color);
|
||||
}
|
||||
if (BRANDING.logo_url) {
|
||||
var img = document.createElement('img');
|
||||
img.src = BRANDING.logo_url;
|
||||
img.className = 'wf-branding-logo';
|
||||
img.alt = '';
|
||||
header.insertBefore(img, header.firstChild);
|
||||
}
|
||||
if (BRANDING.tagline) {
|
||||
var tag = document.createElement('div');
|
||||
tag.className = 'wf-branding-tagline';
|
||||
tag.textContent = BRANDING.tagline;
|
||||
header.appendChild(tag);
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Progress dots ──────────────────
|
||||
(function() {
|
||||
var dotsEl = document.getElementById('progressDots');
|
||||
@@ -224,11 +280,18 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Form rendering ─────────────────
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) {
|
||||
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
|
||||
var _fieldsetIndex = 0;
|
||||
var _fieldsetCount = 0;
|
||||
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
|
||||
var formArea = document.getElementById('formArea');
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
renderProgressiveForm(formArea, FORM_TPL);
|
||||
} else if (FORM_TPL.fields && FORM_TPL.fields.length > 0) {
|
||||
renderForm(formArea, FORM_TPL);
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(container, tpl) {
|
||||
var html = '';
|
||||
@@ -238,6 +301,84 @@
|
||||
}
|
||||
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
|
||||
container.innerHTML = html;
|
||||
applyConditionalFields(tpl.fields);
|
||||
}
|
||||
|
||||
function renderProgressiveForm(container, tpl) {
|
||||
_fieldsetCount = tpl.fieldsets.length;
|
||||
_fieldsetIndex = 0;
|
||||
var html = '';
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
var fs = tpl.fieldsets[s];
|
||||
var display = s === 0 ? '' : ' style="display:none"';
|
||||
html += '<div class="wf-fieldset" id="fieldset_' + s + '"' + display + '>';
|
||||
html += '<div class="wf-fieldset-nav"><span class="step-label">' + escHtml(fs.label) + '</span>';
|
||||
html += '<span class="step-counter">Step ' + (s + 1) + ' of ' + _fieldsetCount + '</span></div>';
|
||||
for (var i = 0; i < fs.fields.length; i++) {
|
||||
html += renderField(fs.fields[i]);
|
||||
}
|
||||
html += '<div class="wf-fieldset-buttons">';
|
||||
if (s > 0) html += '<button onclick="showFieldset(' + (s - 1) + ')">Back</button>';
|
||||
if (s < tpl.fieldsets.length - 1) {
|
||||
html += '<button class="primary" onclick="showFieldset(' + (s + 1) + ')">Next</button>';
|
||||
} else {
|
||||
html += '<button class="primary" id="formSubmitBtn" onclick="submitForm()">Submit</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Apply conditional fields for all fieldsets
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
applyConditionalFields(tpl.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
|
||||
window.showFieldset = function(idx) {
|
||||
if (idx < 0 || idx >= _fieldsetCount) return;
|
||||
document.getElementById('fieldset_' + _fieldsetIndex).style.display = 'none';
|
||||
document.getElementById('fieldset_' + idx).style.display = '';
|
||||
_fieldsetIndex = idx;
|
||||
};
|
||||
|
||||
function applyConditionalFields(fields) {
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var f = fields[i];
|
||||
if (!f.condition) continue;
|
||||
var when = f.condition.when;
|
||||
var srcEl = document.getElementById('ff_' + when);
|
||||
if (!srcEl) continue;
|
||||
(function(field) {
|
||||
var handler = function() { evaluateCondition(field); };
|
||||
srcEl.addEventListener('change', handler);
|
||||
srcEl.addEventListener('input', handler);
|
||||
handler(); // initial evaluation
|
||||
})(f);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCondition(f) {
|
||||
var cond = f.condition;
|
||||
var srcEl = document.getElementById('ff_' + cond.when);
|
||||
var tgtEl = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (!srcEl || !tgtEl) return;
|
||||
|
||||
var val = srcEl.type === 'checkbox' ? srcEl.checked : srcEl.value;
|
||||
var op = cond.op || 'eq';
|
||||
var visible = false;
|
||||
|
||||
switch (op) {
|
||||
case 'exists': visible = val !== '' && val !== false; break;
|
||||
case 'eq': visible = String(val) === String(cond.value); break;
|
||||
case 'neq': visible = String(val) !== String(cond.value); break;
|
||||
case 'in': visible = Array.isArray(cond.value) && cond.value.map(String).indexOf(String(val)) >= 0; break;
|
||||
default: visible = true;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
tgtEl.classList.remove('cond-hidden');
|
||||
} else {
|
||||
tgtEl.classList.add('cond-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(f) {
|
||||
@@ -308,12 +449,22 @@
|
||||
el.textContent = '';
|
||||
});
|
||||
|
||||
// Collect form data
|
||||
// Collect form data (v0.35.0: get all fields from fieldsets or top-level)
|
||||
var allFields = FORM_TPL.fields || [];
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
allFields = [];
|
||||
for (var s = 0; s < FORM_TPL.fieldsets.length; s++) {
|
||||
allFields = allFields.concat(FORM_TPL.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
var data = {};
|
||||
for (var i = 0; i < FORM_TPL.fields.length; i++) {
|
||||
var f = FORM_TPL.fields[i];
|
||||
for (var i = 0; i < allFields.length; i++) {
|
||||
var f = allFields[i];
|
||||
var el = document.getElementById('ff_' + f.key);
|
||||
if (!el) continue;
|
||||
// v0.35.0: Skip hidden conditional fields
|
||||
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
|
||||
if (f.type === 'checkbox') {
|
||||
data[f.key] = el.checked;
|
||||
} else if (f.type === 'number') {
|
||||
@@ -480,19 +631,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Review surface (v0.30.2) ──────────
|
||||
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ──
|
||||
if (STAGE_MODE === 'review') {
|
||||
var reviewArea = document.getElementById('reviewArea');
|
||||
if (reviewArea) {
|
||||
loadReviewSurface(reviewArea);
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
if (dataPanel && actionPanel) {
|
||||
loadReviewSurface(dataPanel, actionPanel);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReviewSurface(container) {
|
||||
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
|
||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
||||
|
||||
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
|
||||
html += '<h3 style="margin-bottom:16px">Review</h3>';
|
||||
// Left panel: structured data card
|
||||
var html = '<div style="padding:24px">';
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
|
||||
@@ -500,33 +653,54 @@
|
||||
});
|
||||
if (resp.ok) {
|
||||
var status = await resp.json();
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
|
||||
|
||||
if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) {
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key)) continue;
|
||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + escHtml(key) + '</td>';
|
||||
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[key])) + '</td>';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
||||
var val = status.stage_data[key];
|
||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
dataPanel.innerHTML = html;
|
||||
|
||||
html += '<div style="display:flex;gap:8px;margin-top:24px">';
|
||||
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
// Right panel: comment input + approve/reject buttons
|
||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
||||
actHtml += '<div style="margin-bottom:16px">';
|
||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="display:flex;gap:8px">';
|
||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & 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 · Ctrl+Shift+Enter: Reject</div>';
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts (v0.35.0)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
document.getElementById('reviewRejectBtn').click();
|
||||
} else {
|
||||
document.getElementById('reviewAdvanceBtn').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
@@ -536,7 +710,7 @@
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
@@ -547,7 +721,8 @@
|
||||
});
|
||||
|
||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
||||
var reason = prompt('Rejection reason:');
|
||||
var comment = document.getElementById('reviewComment').value;
|
||||
var reason = comment || prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
try {
|
||||
@@ -556,7 +731,7 @@
|
||||
body: JSON.stringify({ reason: reason }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// BuildWorkflowModule creates the "workflow" Starlark module for a package.
|
||||
@@ -30,6 +31,7 @@ func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstru
|
||||
"get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)),
|
||||
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)),
|
||||
"reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)),
|
||||
"route": starlark.NewBuiltin("workflow.route", workflowRoute(ctx, stores)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +131,10 @@ func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Th
|
||||
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
|
||||
}
|
||||
|
||||
nextStage := ws.CurrentStage + 1
|
||||
nextStage, err := workflow.ResolveNextStage(stages, ws.CurrentStage, ws.StageData)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: routing error: %w", err)
|
||||
}
|
||||
if nextStage >= len(stages) {
|
||||
// Complete the workflow
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil {
|
||||
@@ -168,3 +173,44 @@ func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
||||
return starlark.String("rejected"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// workflowRoute routes the workflow to a named stage (v0.35.0).
|
||||
// Starlark: workflow.route(channel_id, target_stage, reason)
|
||||
func workflowRoute(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID, targetStage, reason string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.route", args, kwargs, 3, &channelID, &targetStage, &reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: channel not found or not a workflow")
|
||||
}
|
||||
if ws.Status != "active" {
|
||||
return starlark.None, fmt.Errorf("workflow.route: workflow is %s", ws.Status)
|
||||
}
|
||||
|
||||
stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
targetOrdinal, err := workflow.ResolveStageByName(stages, targetStage)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
if targetOrdinal >= len(stages) {
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("completed"), nil
|
||||
}
|
||||
|
||||
if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("routed"), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,11 @@ type ChannelStore interface {
|
||||
|
||||
// MergeSettings merges a JSON object into the channel's settings column.
|
||||
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error
|
||||
|
||||
// ── Monitoring (v0.35.0) ──
|
||||
|
||||
// ListByType returns all channels of a given type (e.g. "workflow").
|
||||
ListByType(ctx context.Context, channelType string) ([]models.Channel, error)
|
||||
}
|
||||
|
||||
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.
|
||||
|
||||
@@ -562,12 +562,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5
|
||||
WHERE id = $6
|
||||
`, workflowID, version, stageData, status, time.Now().UTC(), channelID)
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5,
|
||||
stage_entered_at = $6
|
||||
WHERE id = $7
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -577,10 +579,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -592,11 +594,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`, nextStage, stageData, time.Now().UTC(), channelID)
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3,
|
||||
stage_entered_at = $4
|
||||
WHERE id = $5
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -611,9 +615,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
|
||||
`, stage, time.Now().UTC(), channelID)
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2,
|
||||
stage_entered_at = $3 WHERE id = $4
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -908,6 +914,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = $1
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -224,11 +224,13 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules, surface_pkg_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, sla_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, created_at`,
|
||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds,
|
||||
).Scan(&st.ID, &st.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -236,7 +238,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = $1
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -250,7 +252,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
|
||||
&st.HistoryMode, &st.AutoTransition, &transRules,
|
||||
&st.SurfacePkgID, &st.CreatedAt); err != nil {
|
||||
&st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = formTpl
|
||||
@@ -271,10 +273,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
||||
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9,
|
||||
transition_rules = $10, surface_pkg_id = $11
|
||||
transition_rules = $10, surface_pkg_id = $11, sla_seconds = $12
|
||||
WHERE id = $1`,
|
||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID)
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -497,3 +500,34 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
review_comments, created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = rc
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET review_comments = review_comments || $1::jsonb
|
||||
WHERE id = $2
|
||||
`, "["+string(commentJSON)+"]", assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -563,12 +563,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -578,10 +580,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = ? AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -593,11 +595,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -612,9 +616,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
|
||||
`, stage, time.Now().UTC().Format(timeFmt), channelID)
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ?,
|
||||
stage_entered_at = ? WHERE id = ?
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -866,6 +872,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = ?
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -160,11 +160,11 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
surface_pkg_id, sla_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339))
|
||||
st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = ?
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -187,7 +187,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
|
||||
&stg.HistoryMode, &autoTrans, &transRules,
|
||||
&stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil {
|
||||
&stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stg.FormTemplate = json.RawMessage(formTpl)
|
||||
@@ -209,11 +209,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
||||
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?,
|
||||
transition_rules = ?, surface_pkg_id = ?
|
||||
transition_rules = ?, surface_pkg_id = ?, sla_seconds = ?
|
||||
WHERE id = ?`,
|
||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.ID)
|
||||
st.SurfacePkgID, st.SLASeconds, st.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -504,3 +504,44 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc string
|
||||
var claimedAt, completedAt *time.Time
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = ?
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = json.RawMessage(rc)
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite: read, append, write back
|
||||
var existing string
|
||||
err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
_ = json.Unmarshal([]byte(existing), &arr)
|
||||
arr = append(arr, json.RawMessage(commentJSON))
|
||||
updated, _ := json.Marshal(arr)
|
||||
_, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -54,4 +54,19 @@ type WorkflowStore interface {
|
||||
// TryRoundRobin finds the least-recently-assigned team member and claims.
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error)
|
||||
|
||||
// AddReviewComment appends a comment to an assignment's review_comments array.
|
||||
AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error
|
||||
}
|
||||
|
||||
// ReviewComment is a single review comment on a workflow assignment (v0.35.0).
|
||||
type ReviewComment struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type WorkflowChannelStatus struct {
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"`
|
||||
LastActivityAt *string `json:"last_activity_at"`
|
||||
StageEnteredAt *string `json:"stage_entered_at,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowAssignment is a row from the workflow_assignments table.
|
||||
@@ -23,6 +24,7 @@ type WorkflowAssignment struct {
|
||||
TeamID string `json:"team_id"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
Status string `json:"status"`
|
||||
ReviewComments json.RawMessage `json:"review_comments"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/webhook"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
@@ -22,7 +23,8 @@ func RegisterWorkflowTools(stores store.Stores) {
|
||||
return
|
||||
}
|
||||
Register(&workflowAdvanceTool{stores: stores})
|
||||
log.Println("✅ workflow tools registered (workflow_advance)")
|
||||
Register(&workflowRouteTool{stores: stores})
|
||||
log.Println("✅ workflow tools registered (workflow_advance, workflow_route)")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -98,7 +100,10 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
|
||||
// Merge collected data into stage_data
|
||||
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data)
|
||||
nextStage := currentStage + 1
|
||||
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("routing error: %w", err)
|
||||
}
|
||||
|
||||
if nextStage >= len(stages) {
|
||||
// Workflow complete
|
||||
@@ -151,6 +156,117 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// workflow_route (v0.35.0)
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
// Called by a stage persona to route the workflow to a specific named stage.
|
||||
// Enables AI-triggered routing (escalation, correction loops) and
|
||||
// conditional branching driven by persona judgment.
|
||||
|
||||
type workflowRouteTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func (t *workflowRouteTool) Availability() Require { return RequireWorkflow }
|
||||
|
||||
func (t *workflowRouteTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "workflow_route",
|
||||
DisplayName: "Route Workflow",
|
||||
Category: "workflow",
|
||||
Description: "Route the workflow to a specific stage by name. Use this when the " +
|
||||
"conversation should jump to a particular stage instead of advancing sequentially. " +
|
||||
"Common patterns: escalation to human review, loop back for correction, " +
|
||||
"skip stages based on collected information.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"target_stage": Prop("string", "Name of the stage to route to (case-insensitive)."),
|
||||
"reason": Prop("string", "Brief explanation of why this routing decision was made."),
|
||||
}, []string{"target_stage", "reason"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *workflowRouteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
TargetStage string `json:"target_stage"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.TargetStage == "" {
|
||||
return "", fmt.Errorf("target_stage is required")
|
||||
}
|
||||
|
||||
channelID := execCtx.ChannelID
|
||||
if channelID == "" {
|
||||
return "", fmt.Errorf("no channel context")
|
||||
}
|
||||
|
||||
ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return "", fmt.Errorf("not a workflow channel")
|
||||
}
|
||||
if ws.Status != "active" {
|
||||
return "", fmt.Errorf("workflow is %s, cannot route", ws.Status)
|
||||
}
|
||||
|
||||
stages, err := t.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load stages: %w", err)
|
||||
}
|
||||
|
||||
targetOrdinal, err := workflow.ResolveStageByName(stages, args.TargetStage)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve target: %w", err)
|
||||
}
|
||||
|
||||
// Record routing decision in stage_data
|
||||
routeEntry, _ := json.Marshal(map[string]any{
|
||||
"from": ws.CurrentStage, "to": targetOrdinal,
|
||||
"reason": args.Reason, "ts": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
historyPatch, _ := json.Marshal(map[string]any{
|
||||
"_route_history_latest": json.RawMessage(routeEntry),
|
||||
})
|
||||
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, json.RawMessage(historyPatch))
|
||||
|
||||
if targetOrdinal >= len(stages) {
|
||||
// Route beyond last stage = complete
|
||||
err = t.stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to complete workflow: %w", err)
|
||||
}
|
||||
go TriggerWorkflowOnComplete(ctx, t.stores, *ws.WorkflowID, channelID, mergedData)
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"status": "completed", "message": "Workflow completed via routing.",
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to route: %w", err)
|
||||
}
|
||||
|
||||
targetDef := stages[targetOrdinal]
|
||||
|
||||
// Create assignment if target stage has an assignment team
|
||||
if targetDef.AssignmentTeamID != nil {
|
||||
CreateWorkflowAssignment(ctx, t.stores, channelID, targetOrdinal, *targetDef.AssignmentTeamID)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"status": "active",
|
||||
"current_stage": targetOrdinal,
|
||||
"stage_name": targetDef.Name,
|
||||
"stage_mode": targetDef.StageMode,
|
||||
"message": fmt.Sprintf("Routed to stage %d: %s (reason: %s)", targetOrdinal, targetDef.Name, args.Reason),
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// ── Shared helpers (used by both tool and handler) ─
|
||||
|
||||
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.
|
||||
|
||||
203
server/workflow/routing.go
Normal file
203
server/workflow/routing.go
Normal 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)
|
||||
}
|
||||
@@ -61,6 +61,40 @@
|
||||
}
|
||||
.sb-queue-item:hover .btn-small { opacity: 1; }
|
||||
|
||||
/* Collapsed sidebar — show icon only */
|
||||
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
||||
.sidebar.collapsed .sb-queue-label { display: none; }
|
||||
.sidebar.collapsed .sb-queue-item .btn-small { display: none; }
|
||||
.sidebar.collapsed .sb-queue-badge { display: none !important; }
|
||||
|
||||
/* Unpin button */
|
||||
.sb-queue-unpin {
|
||||
opacity: 0; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 14px; padding: 0 4px; transition: opacity 0.15s;
|
||||
}
|
||||
.sb-queue-item:hover .sb-queue-unpin { opacity: 1; }
|
||||
.sb-queue-unpin:hover { color: var(--text); }
|
||||
.sidebar.collapsed .sb-queue-unpin { display: none; }
|
||||
|
||||
/* Browse workflows button */
|
||||
.sb-queue-browse {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; cursor: pointer; border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-3); transition: background 0.15s;
|
||||
}
|
||||
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
|
||||
.sidebar.collapsed .sb-queue-browse .sb-queue-label { display: none; }
|
||||
.sidebar.collapsed .sb-queue-browse { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
||||
|
||||
/* Browse dialog */
|
||||
.wf-browse-dialog { max-height: 300px; overflow-y: auto; }
|
||||
.wf-browse-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; cursor: pointer; border-radius: 6px;
|
||||
font-size: 13px; transition: background 0.15s;
|
||||
}
|
||||
.wf-browse-row:hover { background: var(--bg-raised); }
|
||||
|
||||
/* ── Team queue panel ────────────────── */
|
||||
|
||||
.wf-queue-panel { padding: 4px 0; }
|
||||
|
||||
128
src/js/workflow-monitor.js
Normal file
128
src/js/workflow-monitor.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,30 @@
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Pinned set ────────────────────────
|
||||
// Only show workflow channels the user has explicitly opened/pinned.
|
||||
// Stored in localStorage to persist across sessions.
|
||||
|
||||
_getPinned() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
|
||||
} catch (_) { return []; }
|
||||
},
|
||||
|
||||
_pin(channelId) {
|
||||
var pinned = this._getPinned();
|
||||
if (pinned.indexOf(channelId) === -1) {
|
||||
pinned.push(channelId);
|
||||
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
|
||||
}
|
||||
},
|
||||
|
||||
_unpin(channelId) {
|
||||
var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
|
||||
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Refresh ─────────────────────────
|
||||
// Called after loadChats() and on assignment changes.
|
||||
|
||||
@@ -45,10 +69,16 @@
|
||||
var badge = document.getElementById('sbQueueBadge');
|
||||
if (!body) return;
|
||||
|
||||
// 1. Workflow channels from App.chats
|
||||
var workflows = (App.chats || []).filter(function(c) {
|
||||
var self = this;
|
||||
var pinned = this._getPinned();
|
||||
|
||||
// 1. Workflow channels — only show pinned ones
|
||||
var allWorkflows = (App.chats || []).filter(function(c) {
|
||||
return c.type === 'workflow';
|
||||
});
|
||||
var workflows = allWorkflows.filter(function(c) {
|
||||
return pinned.indexOf(c.id) !== -1;
|
||||
});
|
||||
|
||||
// 2. Claimed assignments (async, best-effort)
|
||||
var assignments = [];
|
||||
@@ -56,31 +86,32 @@
|
||||
var resp = await API.listMyAssignments();
|
||||
assignments = resp.data || [];
|
||||
this._myAssignments = assignments;
|
||||
// Auto-pin channels from assignments
|
||||
assignments.forEach(function(a) {
|
||||
if (a.channel_id) self._pin(a.channel_id);
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-critical — show channels even if assignments fail
|
||||
}
|
||||
|
||||
var total = workflows.length + assignments.length;
|
||||
var unpinnedCount = allWorkflows.length - workflows.length;
|
||||
if (badge) {
|
||||
badge.textContent = total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
body.innerHTML = '<div class="sb-section-empty">No active workflows</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// Workflow channels
|
||||
// Workflow channels (pinned only)
|
||||
workflows.forEach(function(wf) {
|
||||
var isActive = App.activeId === wf.id;
|
||||
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
|
||||
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
|
||||
'title="' + esc(wf.title) + '">' +
|
||||
'<span class="sb-queue-icon">⚡</span>' +
|
||||
'<span class="sb-queue-icon">⚡</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">×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
@@ -92,19 +123,97 @@
|
||||
|
||||
html += '<div class="sb-queue-item" ' +
|
||||
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
|
||||
'<span class="sb-queue-icon">📋</span>' +
|
||||
'<span class="sb-queue-icon">📋</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]) + '\'>✓</button>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Browse button — shows when there are unpinned workflows available
|
||||
if (unpinnedCount > 0 || total === 0) {
|
||||
html += '<div class="sb-queue-browse" data-action="WorkflowQueue.browse">' +
|
||||
'<span class="sb-queue-icon">+</span>' +
|
||||
'<span class="sb-queue-label">Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Unpin ──────────────────────────
|
||||
|
||||
unpin(channelId) {
|
||||
this._unpin(channelId);
|
||||
},
|
||||
|
||||
// ── Browse dialog ─────────────────────
|
||||
|
||||
browse() {
|
||||
var self = this;
|
||||
var pinned = this._getPinned();
|
||||
var allWorkflows = (App.chats || []).filter(function(c) {
|
||||
return c.type === 'workflow';
|
||||
});
|
||||
var unpinned = allWorkflows.filter(function(c) {
|
||||
return pinned.indexOf(c.id) === -1;
|
||||
});
|
||||
|
||||
if (unpinned.length === 0) {
|
||||
UI.toast('No available workflows', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing browse overlay
|
||||
var existing = document.getElementById('wfBrowseOverlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'wfBrowseOverlay';
|
||||
overlay.className = 'confirm-overlay';
|
||||
|
||||
var rows = '';
|
||||
unpinned.forEach(function(wf) {
|
||||
rows += '<div class="wf-browse-row" data-wf-id="' + wf.id + '">' +
|
||||
'<span class="sb-queue-icon">⚡</span>' +
|
||||
'<span>' + esc(wf.title || 'Workflow') + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="confirm-dialog" style="max-width:400px">' +
|
||||
'<div class="confirm-header">Browse Workflows</div>' +
|
||||
'<div class="confirm-body" style="padding:0">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;margin:0;padding:12px 16px 8px">Select a workflow to add to your sidebar:</p>' +
|
||||
'<div class="wf-browse-dialog">' + rows + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="confirm-footer">' +
|
||||
'<button class="btn-small" data-action="cancel">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
var row = e.target.closest('.wf-browse-row');
|
||||
if (row) {
|
||||
var wfId = row.getAttribute('data-wf-id');
|
||||
overlay.remove();
|
||||
self._pin(wfId);
|
||||
self.refresh();
|
||||
if (typeof selectChannel === 'function') selectChannel(wfId);
|
||||
return;
|
||||
}
|
||||
if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
// ── Open Assignment ─────────────────
|
||||
|
||||
openAssignment(assignmentId, channelId) {
|
||||
this._pin(channelId);
|
||||
if (typeof selectChannel === 'function') {
|
||||
selectChannel(channelId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user