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