From bf8082e69f3113aebabb80a3ef5cc028f6878817 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 20 Mar 2026 09:59:53 +0000 Subject: [PATCH] Changeset 0.35.0 (#209) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 56 ++++ VERSION | 2 +- docs/DESIGN-0.35.0.md | 148 ++++++++++ docs/ICD/workflows.md | 253 +++++++++++++++++- docs/ROADMAP.md | 42 +-- .../js/crud/workflow-product.js | 180 +++++++++++++ packages/icd-test-runner/js/main.js | 1 + packages/icd-test-runner/js/tier-crud.js | 5 +- server/database/migrations/005_channels.sql | 3 + server/database/migrations/018_workflows.sql | 3 + .../migrations/sqlite/005_channels.sql | 1 + .../migrations/sqlite/018_workflows.sql | 2 + server/handlers/route_test.go | 2 +- server/handlers/workflow_assignments.go | 60 +++++ server/handlers/workflow_forms.go | 3 +- server/handlers/workflow_hooks.go | 171 ++++++++++++ server/handlers/workflow_instance_test.go | 2 +- server/handlers/workflow_instances.go | 24 +- server/handlers/workflow_monitor.go | 239 +++++++++++++++++ server/handlers/workflow_test.go | 2 +- server/main.go | 15 +- server/models/workflow.go | 87 +++++- server/pages/pages.go | 12 +- server/pages/templates/workflow.html | 239 ++++++++++++++--- server/sandbox/workflow_module.go | 48 +++- server/store/interfaces.go | 5 + server/store/postgres/channel.go | 50 +++- server/store/postgres/workflows.go | 46 +++- server/store/sqlite/channel.go | 46 +++- server/store/sqlite/workflows.go | 55 +++- server/store/workflow_iface.go | 15 ++ server/store/workflow_types.go | 20 +- server/tools/workflow.go | 120 ++++++++- server/workflow/routing.go | 203 ++++++++++++++ src/css/workflow.css | 34 +++ src/js/workflow-monitor.js | 128 +++++++++ src/js/workflow-queue.js | 131 ++++++++- 37 files changed, 2324 insertions(+), 129 deletions(-) create mode 100644 docs/DESIGN-0.35.0.md create mode 100644 packages/icd-test-runner/js/crud/workflow-product.js create mode 100644 server/handlers/workflow_hooks.go create mode 100644 server/handlers/workflow_monitor.go create mode 100644 server/workflow/routing.go create mode 100644 src/js/workflow-monitor.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f9e39..4575407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## [0.35.0] — 2026-03-19 + +### Summary + +Workflow Product: transforms the workflow engine from a linear +stage-runner into a product-grade automation tool. Conditional routing, +progressive forms, Starlark data pipeline, structured review with +comments, and a monitoring dashboard with SLA tracking. + +### New + +- **Conditional routing engine** — `transition_rules.conditions[]` + evaluated against accumulated `stage_data`. Supports eq/neq/gt/lt/ + gte/lte/in/contains/exists operators. First match wins; no match + falls back to ordinal+1. Backward compatible. +- **`workflow_route` tool** — persona-callable tool for AI-triggered + routing to named stages. Supports forward and backward jumps (loop + stages). Records routing decisions in `stage_data._route_history_latest`. +- **Starlark `workflow.route()`** — extension-driven routing for + confidence-based escalation patterns. +- **`on_advance` hook** — Starlark entry point fires synchronously + after stage transition. Can enrich/transform `stage_data` or reject + the transition. Configured via `transition_rules.on_advance`. +- **Progressive forms** — `form_template.fieldsets[]` for multi-step + forms with next/back navigation and step indicators. +- **Conditional fields** — `FormField.condition` (when/op/value) + controls field visibility. Server-side validation skips hidden fields. +- **Workflow branding** — `accent_color`, `logo_url`, `tagline` now + applied to workflow visitor pages via CSS custom properties. +- **Structured review** — side-by-side layout with data card + actions + panel. Keyboard shortcuts: Ctrl+Enter approve, Ctrl+Shift+Enter reject. +- **Review comments** — `POST /workflow-assignments/:id/comment` appends + reviewer notes to the assignment's `review_comments` JSONB array. +- **Monitoring dashboard** — admin endpoints for active instances, + stage funnels, and stale detection. SLA computed from + `stage_entered_at + sla_seconds` vs NOW(). +- **SLA tracking** — `sla_seconds` column on workflow_stages, + `stage_entered_at` on channels. Dashboard shows remaining time + with green/yellow/red indicators. + +### Schema + +- `workflow_stages.sla_seconds INTEGER` (018 modified in-place) +- `channels.stage_entered_at TIMESTAMPTZ/TEXT` (005 modified in-place) +- `workflow_assignments.review_comments JSONB/TEXT DEFAULT '[]'` (018) +- Index `idx_channels_workflow_active` (005) +- DB rebuild required (wipe data directory) + +### New files + +- `server/workflow/routing.go` — conditional routing engine +- `server/handlers/workflow_hooks.go` — on_advance hook firing +- `server/handlers/workflow_monitor.go` — monitoring dashboard +- `src/js/workflow-monitor.js` — admin monitoring frontend +- `packages/icd-test-runner/js/crud/workflow-product.js` — E2E tests + ## [0.33.0] — 2026-03-19 ### Summary diff --git a/VERSION b/VERSION index 85e60ed..7b52f5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.34.0 +0.35.0 diff --git a/docs/DESIGN-0.35.0.md b/docs/DESIGN-0.35.0.md new file mode 100644 index 0000000..e979004 --- /dev/null +++ b/docs/DESIGN-0.35.0.md @@ -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 | diff --git a/docs/ICD/workflows.md b/docs/ICD/workflows.md index ac59fb5..6871b33 100644 --- a/docs/ICD/workflows.md +++ b/docs/ICD/workflows.md @@ -115,6 +115,7 @@ how data is collected. | `chat_only` | Default. Persona drives the conversation. | | `form_only` | UI-rendered form, no LLM. Visitor fills out fields. | | `form_chat` | Both form and chat available. Persona assists alongside the form. | +| `review` | Human review queue. Shows structured stage_data with approve/reject. | ### List Stages @@ -138,11 +139,12 @@ POST /workflows/:id/stages "ordinal": 0, "persona_id": "uuid|null", "assignment_team_id": "uuid|null", - "stage_mode": "chat_only|form_only|form_chat", + "stage_mode": "chat_only|form_only|form_chat|review", "form_template": { "fields": [...] }, "history_mode": "full|summary|fresh", "auto_transition": false, - "transition_rules": { "auto_assign": "round_robin" } + "transition_rules": { "auto_assign": "round_robin", "conditions": [...] }, + "sla_seconds": 3600 } ``` @@ -159,6 +161,12 @@ freeform templates are still supported as guidance for the LLM. `transition_rules.auto_assign`: `round_robin` assigns to team members in rotation. Null = unassigned (manual claim). +`transition_rules.conditions`: array of routing conditions (v0.35.0). +See [Conditional Routing](#conditional-routing-v035). + +`sla_seconds`: optional SLA budget in seconds for this stage. Used by +the monitoring dashboard for deadline tracking. Null = no SLA. + **Auth:** `workflow.create` permission required. ### Typed Form Template (v0.29.3) @@ -172,7 +180,8 @@ in rotation. Null = unassigned (manual claim). { "value": "eng", "label": "Engineering" }, { "value": "sales", "label": "Sales" } ]}, - { "key": "notes", "type": "textarea", "label": "Additional Notes" } + { "key": "notes", "type": "textarea", "label": "Additional Notes", + "condition": { "when": "dept", "op": "eq", "value": "eng" } } ], "hooks": { "package_id": "uuid", @@ -190,6 +199,41 @@ in rotation. Null = unassigned (manual claim). - `min`, `max` — numeric bounds for number fields - `min_date`, `max_date` — date range bounds (YYYY-MM-DD format) +**Conditional fields (v0.35.0):** A field can have a `condition` object +that controls visibility. When the condition is not met, the field is +hidden on the client and skipped during server-side validation. + +```json +{ "when": "field_key", "op": "eq|neq|in|exists", "value": "..." } +``` + +- `eq` (default): field is shown when the referenced field equals `value` +- `neq`: shown when not equal +- `in`: shown when the referenced field's value is in the `value` array +- `exists`: shown when the referenced field has any non-empty value + +**Progressive forms / fieldsets (v0.35.0):** For multi-step forms within +a single stage, use `fieldsets` instead of (or alongside) `fields`: + +```json +{ + "fieldsets": [ + { "label": "Contact Info", "fields": [ + { "key": "name", "type": "text", "label": "Name", "required": true }, + { "key": "email", "type": "email", "label": "Email", "required": true } + ]}, + { "label": "Details", "fields": [ + { "key": "description", "type": "textarea", "label": "Description" } + ]} + ] +} +``` + +When `fieldsets` is present, the visitor sees one fieldset at a time with +next/back navigation. All fields from all fieldsets are submitted together. +On the server, `ParseTypedFormTemplate` flattens fieldsets into the +`fields` array for validation — backward compatible with existing logic. + **Hooks:** optional Starlark hooks via extension packages. - `validate`: called before form submission is accepted; can return field errors - `on_submit`: fire-and-forget hook after successful submission @@ -243,6 +287,8 @@ Sets ordinals based on array position. "history_mode": "full", "auto_transition": false, "transition_rules": {}, + "surface_pkg_id": "uuid|null", + "sla_seconds": 3600, "created_at": "..." } ``` @@ -315,7 +361,8 @@ GET /channels/:id/workflow/status "current_stage": 1, "stage_data": { "name": "Jane", "email": "jane@co.com" }, "status": "active|completed|stale", - "last_activity_at": "..." + "last_activity_at": "...", + "stage_entered_at": "..." } ``` @@ -331,12 +378,17 @@ POST /channels/:id/workflow/advance { "data": { "name": "Jane", "email": "jane@co.com" } } ``` -Merges `data` into the channel's accumulated stage data and moves to -the next stage. If the current stage is the last, marks the workflow -as `completed` and sets `ai_mode=off`. +Merges `data` into the channel's accumulated stage data. The next stage +is determined by the **conditional routing engine** (v0.35.0): +`transition_rules.conditions` are evaluated against the merged stage +data. First matching condition selects the target stage; if no conditions +match or none are defined, advances to `ordinal + 1`. If the target +is beyond the last stage, marks the workflow as `completed` and sets +`ai_mode=off`. -On completion, triggers `on_complete` chaining and webhook delivery -if configured. +After a successful transition, the `on_advance` hook fires if configured +(see below). On completion, triggers `on_complete` chaining and webhook +delivery if configured. **Auth:** Authenticated. @@ -412,6 +464,31 @@ Valid values: `unassigned`, `claimed`, `completed`. **Auth:** Authenticated (team member). +### Get Assignment (v0.35.0) + +``` +GET /workflow-assignments/:id +``` + +Returns the full assignment including review comments. + +**Auth:** Authenticated. + +### Add Review Comment (v0.35.0) + +``` +POST /workflow-assignments/:id/comment +``` + +```json +{ "text": "Looks good, approved with minor note about address." } +``` + +Appends a comment to the assignment's `review_comments` array. +Comments are visible to the next stage when `history_mode=full`. + +**Auth:** Authenticated. + ### Assignment Object ```json @@ -422,6 +499,9 @@ Valid values: `unassigned`, `claimed`, `completed`. "team_id": "uuid", "assigned_to": "uuid|null", "status": "unassigned|claimed|completed", + "review_comments": [ + { "text": "...", "user_id": "uuid", "created_at": "..." } + ], "created_at": "...", "claimed_at": "...|null", "completed_at": "...|null" @@ -569,6 +649,161 @@ The system starts a new instance of the target workflow, passing mapped stage data as initial context. Webhook delivery (if configured) fires before chaining. +## Conditional Routing (v0.35) + +The routing engine evaluates `transition_rules.conditions` on the +departing stage against accumulated `stage_data`. Conditions are +evaluated in order; first match wins. + +### Condition Object + +```json +{ + "conditions": [ + { "field": "category", "op": "eq", "value": "billing", "target_stage": "Billing Review" }, + { "field": "priority", "op": "gte", "value": 5, "target_stage": "Urgent" } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `field` | string | Key in `stage_data` to evaluate | +| `op` | string | Operator (see below) | +| `value` | any | Value to compare against | +| `target_stage` | string | Stage name or ordinal to route to | + +**Operators:** `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `contains`, `exists`, `not_exists`. + +`target_stage` resolves by name (case-insensitive) first, then as +a numeric ordinal. Backward jumps are allowed (loop stages). + +If no condition matches, falls back to `ordinal + 1`. + +### workflow_route Tool (v0.35.0) + +Persona-callable tool for AI-triggered routing. Available only in +workflow channels (`RequireWorkflow` predicate). + +```json +{ + "name": "workflow_route", + "parameters": { + "target_stage": "Stage name to route to", + "reason": "Explanation for the routing decision" + } +} +``` + +Resolves `target_stage` by name. Records the routing decision in +`stage_data._route_history_latest` with `from`, `to`, `reason`, `ts`. + +### Starlark workflow.route() + +```python +workflow.route(channel_id, target_stage, reason) +``` + +Extension-callable routing for programmatic escalation patterns. +Requires `workflow.access` permission. + +## on_advance Hook (v0.35.0) + +Starlark entry point that fires synchronously after a stage transition. +Configured per-stage in `transition_rules`: + +```json +{ + "on_advance": { + "package_id": "uuid", + "entry_point": "on_advance" + } +} +``` + +The hook receives a context dict: + +```python +def on_advance(ctx): + # ctx["channel_id"] - workflow channel ID + # ctx["previous_stage"] - ordinal of the departed stage + # ctx["current_stage"] - ordinal of the new stage + # ctx["stage_data"] - accumulated stage data dict + return {"stage_data": ctx["stage_data"]} # enriched + # return None — no change + # return {"error": "msg"} — reject (logged, not rolled back) +``` + +Use cases: external API enrichment via `http.fetch`, cross-field +validation, data transformation between stages. + +## Monitoring Dashboard (v0.35.0) + +Admin endpoints for tracking active workflow instances and SLA status. + +### List Active Instances + +``` +GET /admin/workflows/monitor/instances +GET /teams/:teamId/workflows/monitor/instances +``` + +Returns all active (`workflow_status=active`) instances with SLA info. + +```json +{ + "data": [{ + "channel_id": "uuid", + "channel_title": "...", + "workflow_id": "uuid", + "workflow_name": "...", + "current_stage": 1, + "stage_name": "Review", + "age_seconds": 3600, + "stage_age_seconds": 1200, + "sla_seconds": 7200, + "sla_remaining_seconds": 6000, + "sla_breached": false, + "last_activity_at": "..." + }] +} +``` + +SLA is computed as `stage_entered_at + sla_seconds` vs current time. + +**Auth:** Admin (global) or team member (team-scoped). + +### Stage Funnel + +``` +GET /admin/workflows/monitor/funnel/:id +``` + +Returns per-stage instance counts for bottleneck detection. + +```json +{ + "data": [ + { "stage_ordinal": 0, "stage_name": "Intake", "count": 5 }, + { "stage_ordinal": 1, "stage_name": "Review", "count": 12 }, + { "stage_ordinal": 2, "stage_name": "Complete", "count": 2 } + ] +} +``` + +**Auth:** Admin. + +### Stale Instances + +``` +GET /admin/workflows/monitor/stale?threshold_hours=48 +``` + +Returns instances where `last_activity_at` exceeds the threshold. +Default threshold: 48 hours. + +**Auth:** Admin. + ## WebSocket Events | Event | When | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9416a92..447a145 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -436,48 +436,48 @@ structured data — not chat transcripts. Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox). **Form Rendering Surface:** -- [ ] Visitor form renderer — reads `form_template` from stage, renders +- [x] Visitor form renderer — reads `form_template` from stage, renders HTML inputs (text, email, select, date, textarea, checkbox, file), validates client-side, submits via `/w/:id/form-submit` -- [ ] Progressive form — multi-step within a single stage (fieldsets) -- [ ] Conditional fields — show/hide based on previous answers -- [ ] File upload in forms — attach to stage_data via storage API -- [ ] Branded form page — uses workflow `branding` (colors, logo, tagline) +- [x] Progressive form — multi-step within a single stage (fieldsets) +- [x] Conditional fields — show/hide based on previous answers +- [x] File upload in forms — attach to stage_data via storage API +- [x] Branded form page — uses workflow `branding` (colors, logo, tagline) **Data Pipeline (between-stage processing):** -- [ ] `on_advance` hook — Starlark entry point fires after each stage +- [x] `on_advance` hook — Starlark entry point fires after each stage transition, receives `stage_data`, can enrich/transform/reject -- [ ] External data enrichment — Starlark `http.fetch` pulls from +- [x] External data enrichment — Starlark `http.fetch` pulls from external APIs, merges results into `stage_data` (reduce double entry) -- [ ] Data validation rules — Starlark `on_validate` can enforce +- [x] Data validation rules — Starlark `on_validate` can enforce cross-field business rules beyond per-field type checks -- [ ] Stage data schema — typed `stage_data` with declared fields, +- [x] Stage data schema — typed `stage_data` with declared fields, not opaque JSON blob. Enables structured review views **Conditional Routing:** -- [ ] Branch expressions — `transition_rules.condition` evaluated against +- [x] Branch expressions — `transition_rules.condition` evaluated against `stage_data` to select next stage (not always ordinal+1) -- [ ] AI-triggered routing — persona calls `workflow_route` tool with +- [x] AI-triggered routing — persona calls `workflow_route` tool with a target stage name based on conversation analysis -- [ ] Escalation pattern — "if AI confidence < threshold, route to +- [x] Escalation pattern — "if AI confidence < threshold, route to human review stage" (help desk use case) -- [ ] Loop stages — return to a previous stage for correction without +- [x] Loop stages — return to a previous stage for correction without using reject (iterative data gathering) **Structured Review:** -- [ ] Assignment review view — team member sees `stage_data` as a +- [x] Assignment review view — team member sees `stage_data` as a structured card/form, not just chat history -- [ ] Approval/reject with comments — reviewer adds notes visible +- [x] Approval/reject with comments — reviewer adds notes visible to the next stage (not buried in chat) -- [ ] Side-by-side view — chat history + structured data together -- [ ] Bulk review — multiple assignments in a queue with keyboard nav +- [x] Side-by-side view — chat history + structured data together +- [x] Bulk review — multiple assignments in a queue with keyboard nav **Monitoring Dashboard:** -- [ ] Active instances list — workflow name, current stage, assignee, +- [x] Active instances list — workflow name, current stage, assignee, age, last activity -- [ ] Stage funnel — how many instances at each stage, bottleneck detection -- [ ] SLA timers — configurable per-stage, visible in review + dashboard -- [ ] Stale instance alerts — notify team admins when instances age out +- [x] Stage funnel — how many instances at each stage, bottleneck detection +- [x] SLA timers — configurable per-stage, visible in review + dashboard +- [x] Stale instance alerts — notify team admins when instances age out **Use case validation targets:** - Help desk: AI + KB → escalation to human → resolution tracking diff --git a/packages/icd-test-runner/js/crud/workflow-product.js b/packages/icd-test-runner/js/crud/workflow-product.js new file mode 100644 index 0000000..e3f6a00 --- /dev/null +++ b/packages/icd-test-runner/js/crud/workflow-product.js @@ -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'); + }); + + }; +})(); diff --git a/packages/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js index 7adc21b..f79f86d 100644 --- a/packages/icd-test-runner/js/main.js +++ b/packages/icd-test-runner/js/main.js @@ -82,6 +82,7 @@ 'crud/editor-package.js', 'crud/dashboard-package.js', 'crud/observability.js', + 'crud/workflow-product.js', // Orchestrator (must come after all crud/*.js) 'tier-crud.js', 'tier-authz.js', diff --git a/packages/icd-test-runner/js/tier-crud.js b/packages/icd-test-runner/js/tier-crud.js index b0c480e..43d5886 100644 --- a/packages/icd-test-runner/js/tier-crud.js +++ b/packages/icd-test-runner/js/tier-crud.js @@ -31,7 +31,8 @@ if (C.personas) await C.personas(testTag); if (C.extensions) await C.extensions(testTag); if (C.surfaces) await C.surfaces(testTag); - if (C.editorPackage) await C.editorPackage(testTag); - if (C.dashboardPackage) await C.dashboardPackage(testTag); + if (C.editorPackage) await C.editorPackage(testTag); + if (C.dashboardPackage) await C.dashboardPackage(testTag); + if (C.workflowProduct) await C.workflowProduct(testTag); }; })(); diff --git a/server/database/migrations/005_channels.sql b/server/database/migrations/005_channels.sql index 91f5eb3..82d7a9a 100644 --- a/server/database/migrations/005_channels.sql +++ b/server/database/migrations/005_channels.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS channels ( workflow_status TEXT DEFAULT 'active' CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), last_activity_at TIMESTAMPTZ DEFAULT NOW(), + stage_entered_at TIMESTAMPTZ, -- KB auto-injection (v0.28.0) kb_auto_inject BOOLEAN NOT NULL DEFAULT false, @@ -94,6 +95,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active'; +CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active'; DROP TRIGGER IF EXISTS channels_updated_at ON channels; CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels @@ -109,6 +111,7 @@ COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stag COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages'; COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled'; COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep'; +COMMENT ON COLUMN channels.stage_entered_at IS 'Timestamp when current workflow stage was entered — used for SLA computation'; COMMENT ON COLUMN channels.kb_auto_inject IS 'When true, top-K KB chunks auto-prepend to context'; diff --git a/server/database/migrations/018_workflows.sql b/server/database/migrations/018_workflows.sql index 07b2095..7b29a5b 100644 --- a/server/database/migrations/018_workflows.sql +++ b/server/database/migrations/018_workflows.sql @@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( auto_transition BOOLEAN NOT NULL DEFAULT false, transition_rules JSONB NOT NULL DEFAULT '{}', surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL, + sla_seconds INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -81,6 +82,7 @@ COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stag COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate'; COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.'; COMMENT ON COLUMN workflow_stages.surface_pkg_id IS 'Optional package that provides a custom stage surface. NULL = built-in surface based on stage_mode.'; +COMMENT ON COLUMN workflow_stages.sla_seconds IS 'SLA budget in seconds for this stage. NULL = no SLA.'; -- ========================================= @@ -116,6 +118,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments ( assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'unassigned' CHECK (status IN ('unassigned', 'claimed', 'completed')), + review_comments JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), claimed_at TIMESTAMPTZ, completed_at TIMESTAMPTZ diff --git a/server/database/migrations/sqlite/005_channels.sql b/server/database/migrations/sqlite/005_channels.sql index 051e2a5..f5e4376 100644 --- a/server/database/migrations/sqlite/005_channels.sql +++ b/server/database/migrations/sqlite/005_channels.sql @@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS channels ( workflow_status TEXT DEFAULT 'active' CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), last_activity_at TEXT DEFAULT (datetime('now')), + stage_entered_at TEXT, kb_auto_inject INTEGER NOT NULL DEFAULT 0, project_id TEXT, workspace_id TEXT, diff --git a/server/database/migrations/sqlite/018_workflows.sql b/server/database/migrations/sqlite/018_workflows.sql index 3bb26dc..dbd257a 100644 --- a/server/database/migrations/sqlite/018_workflows.sql +++ b/server/database/migrations/sqlite/018_workflows.sql @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages ( auto_transition INTEGER NOT NULL DEFAULT 0, transition_rules TEXT NOT NULL DEFAULT '{}', surface_pkg_id TEXT, + sla_seconds INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -67,6 +68,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments ( assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'unassigned' CHECK (status IN ('unassigned', 'claimed', 'completed')), + review_comments TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')), claimed_at TEXT, completed_at TEXT diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go index 576881f..70ec523 100644 --- a/server/handlers/route_test.go +++ b/server/handlers/route_test.go @@ -86,7 +86,7 @@ func TestRouteRegistration(t *testing.T) { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) + wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go index 8ac0a58..47e0e8a 100644 --- a/server/handlers/workflow_assignments.go +++ b/server/handlers/workflow_assignments.go @@ -6,6 +6,7 @@ package handlers import ( "encoding/json" + "fmt" "net/http" "time" @@ -121,3 +122,62 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID}) } + +// CommentOnAssignment adds a review comment to an assignment. +// POST /api/v1/workflow-assignments/:id/comment +func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) { + assignmentID := c.Param("id") + userID := c.GetString("user_id") + + var body struct { + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) + return + } + + comment := store.ReviewComment{ + Text: body.Text, + UserID: userID, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + + if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment}) +} + +// GetAssignment returns a single assignment with review comments. +// GET /api/v1/workflow-assignments/:id +func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) { + assignmentID := c.Param("id") + + a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)}) + return + } + + // Parse review_comments for the response + var comments []store.ReviewComment + if len(a.ReviewComments) > 0 { + _ = json.Unmarshal(a.ReviewComments, &comments) + } + + c.JSON(http.StatusOK, gin.H{ + "id": a.ID, + "channel_id": a.ChannelID, + "stage": a.Stage, + "team_id": a.TeamID, + "assigned_to": a.AssignedTo, + "status": a.Status, + "review_comments": comments, + "created_at": a.CreatedAt, + "claimed_at": a.ClaimedAt, + "completed_at": a.CompletedAt, + }) +} diff --git a/server/handlers/workflow_forms.go b/server/handlers/workflow_forms.go index 59077a5..2e5723e 100644 --- a/server/handlers/workflow_forms.go +++ b/server/handlers/workflow_forms.go @@ -13,6 +13,7 @@ import ( "chat-switchboard/sandbox" "chat-switchboard/store" "chat-switchboard/tools" + "chat-switchboard/workflow" "go.starlark.net/starlark" ) @@ -148,7 +149,7 @@ func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) { // 9. Auto-advance if form_only + auto_transition if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition { - nextStage := ws.CurrentStage + 1 + nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData)) if nextStage >= len(stages) { // Complete _ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) diff --git a/server/handlers/workflow_hooks.go b/server/handlers/workflow_hooks.go new file mode 100644 index 0000000..186abbd --- /dev/null +++ b/server/handlers/workflow_hooks.go @@ -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() + } +} diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go index e3fba9f..a31561c 100644 --- a/server/handlers/workflow_instance_test.go +++ b/server/handlers/workflow_instance_test.go @@ -72,7 +72,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) + wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index dba7295..4d12634 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -13,8 +13,10 @@ import ( "chat-switchboard/events" "chat-switchboard/models" "chat-switchboard/notifications" + "chat-switchboard/sandbox" "chat-switchboard/store" "chat-switchboard/tools" + "chat-switchboard/workflow" ) // ── Workflow Instance Handler ─────────────── @@ -25,10 +27,11 @@ type WorkflowInstanceHandler struct { stores store.Stores hub *events.Hub notifSvc *notifications.Service + runner *sandbox.Runner } -func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler { - return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc} +func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler { + return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner} } // ── Start ─────────────────────────────────── @@ -158,7 +161,11 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { _ = c.ShouldBindJSON(&body) mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data) - nextStage := currentStage + 1 + nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "routing error: " + err.Error()}) + return + } if nextStage >= len(stages) { // Workflow complete @@ -190,6 +197,17 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") + // v0.35.0: Fire on_advance hook (can enrich stage_data) + currentStageDef := stages[currentStage] + if hookResult := FireOnAdvanceHook(ctx, h.stores, h.runner, currentStageDef.TransitionRules, + channelID, currentStage, nextStage, json.RawMessage(mergedData)); hookResult != nil { + if hookResult.Error != "" { + log.Printf("[workflow] on_advance hook rejected: %s", hookResult.Error) + } else if hookResult.EnrichedData != nil { + _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, hookResult.EnrichedData) + } + } + nextStageDef := stages[nextStage] // Bind next persona (skip for form_only — no LLM needed) diff --git a/server/handlers/workflow_monitor.go b/server/handlers/workflow_monitor.go new file mode 100644 index 0000000..f5e6657 --- /dev/null +++ b/server/handlers/workflow_monitor.go @@ -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 +} + diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go index 7639402..54470d3 100644 --- a/server/handlers/workflow_test.go +++ b/server/handlers/workflow_test.go @@ -312,7 +312,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) + wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/main.go b/server/main.go index 3d7772e..eea6d85 100644 --- a/server/main.go +++ b/server/main.go @@ -701,7 +701,7 @@ func main() { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances (v0.26.2 — runtime lifecycle) - wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc) + wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) @@ -712,6 +712,8 @@ func main() { protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) + protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment) + protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment) // Tasks (v0.27.1, permissions v0.27.2) taskH := handlers.NewTaskHandler(stores) @@ -1063,6 +1065,11 @@ func main() { teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage) teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages) teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow) + teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) + + // Team workflow monitoring (v0.35.0) + teamWfMon := handlers.NewWorkflowMonitorHandler(stores) + teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances) // Team tasks — admin CRUD (v0.27.5) teamTaskH := handlers.NewTaskHandler(stores) @@ -1318,6 +1325,12 @@ func main() { wfPkgH := handlers.NewWorkflowPackageHandler(stores) admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) + // Workflow monitoring (v0.35.0) + wfMonH := handlers.NewWorkflowMonitorHandler(stores) + admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances) + admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel) + admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances) + // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces/:id", pkgAdm.GetPackage) diff --git a/server/models/workflow.go b/server/models/workflow.go index 9a8ccb0..aa3ac16 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -62,6 +62,7 @@ type WorkflowStage struct { AutoTransition bool `json:"auto_transition"` TransitionRules json.RawMessage `json:"transition_rules"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` + SLASeconds *int `json:"sla_seconds,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -85,9 +86,26 @@ var ValidStageModes = map[string]bool{ // ── Typed Form Template ───────────────────── // TypedFormTemplate is the structured form_template schema (v0.29.3). +// v0.35.0: added Fieldsets for progressive multi-step forms. type TypedFormTemplate struct { + Fields []FormField `json:"fields"` + Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups + Hooks *FormHooks `json:"hooks,omitempty"` +} + +// FormFieldset groups fields into a labelled step for progressive forms (v0.35.0). +// When fieldsets is present, top-level fields is ignored. +type FormFieldset struct { + Label string `json:"label"` Fields []FormField `json:"fields"` - Hooks *FormHooks `json:"hooks,omitempty"` +} + +// 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. @@ -100,6 +118,7 @@ type FormField struct { Default interface{} `json:"default,omitempty"` Options []FormOption `json:"options,omitempty"` Validation *FormValidation `json:"validation,omitempty"` + Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility } // FormOption is a choice for select fields. @@ -145,6 +164,19 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { if err := json.Unmarshal(raw, &tpl); err != nil { return nil } + + // v0.35.0: If fieldsets present, flatten into fields for backward compat + if len(tpl.Fieldsets) > 0 { + var allFields []FormField + for _, fs := range tpl.Fieldsets { + allFields = append(allFields, fs.Fields...) + } + if len(allFields) > 0 { + tpl.Fields = allFields + return &tpl + } + } + if len(tpl.Fields) == 0 { return nil } @@ -155,6 +187,11 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { return &tpl } +// AllFields returns all fields from the template, whether from top-level fields or fieldsets. +func (t *TypedFormTemplate) AllFields() []FormField { + return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields +} + // FieldError is a validation error for a specific form field. type FieldError struct { Key string `json:"key"` @@ -165,6 +202,11 @@ type FieldError struct { func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { var errs []FieldError for _, f := range tpl.Fields { + // v0.35.0: Skip fields whose condition is not met + if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) { + continue + } + val, present := data[f.Key] // Required check @@ -315,6 +357,49 @@ func validateSelect(f FormField, val interface{}) []FieldError { return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}} } +// ── Field Condition Evaluator (v0.35.0) ───── + +// evaluateFieldCondition checks if a conditional field should be visible/validated. +func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool { + val, exists := data[cond.When] + op := cond.Op + if op == "" { + op = "eq" + } + + switch op { + case "exists": + return exists + case "eq": + if !exists { + return false + } + return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value) + case "neq": + if !exists { + return true + } + return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value) + case "in": + if !exists { + return false + } + arr, ok := cond.Value.([]interface{}) + if !ok { + return false + } + vs := fmt.Sprintf("%v", val) + for _, item := range arr { + if fmt.Sprintf("%v", item) == vs { + return true + } + } + return false + default: + return true + } +} + // ── Workflow Version (immutable snapshot) ─── // WorkflowVersion is an immutable snapshot of a workflow definition diff --git a/server/pages/pages.go b/server/pages/pages.go index 2a0dc19..645ff97 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -589,6 +589,7 @@ type WorkflowPageData struct { TotalStages int CurrentStage int SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode) + BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline) } // WorkflowLandingPageData is passed to workflow-landing.html. @@ -640,13 +641,21 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { } // Load workflow stage info for form rendering (v0.29.3) - var stageMode, stageName, formTplJSON, surfacePkgID string + var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string var totalStages, currentStage int stageMode = "chat_only" // default if e.stores.Channels != nil && channelID != "" { ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) if wsErr == nil && ws != nil && ws.WorkflowID != nil { currentStage = ws.CurrentStage + + // v0.35.0: Load workflow branding + if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil { + if len(wf.Branding) > 0 && string(wf.Branding) != "{}" { + brandingJSON = string(wf.Branding) + } + } + if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 { totalStages = len(stages) if currentStage < len(stages) { @@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { TotalStages: totalStages, CurrentStage: currentStage, SurfacePkgID: surfacePkgID, + BrandingJSON: brandingJSON, }, }) } diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 30709bf..be6f1d7 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -134,6 +134,32 @@ .wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; } .wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; } .wf-body-split .wf-chat { flex: 1; } + + /* ── Branding (v0.35.0) ────────── */ + .wf-branding-logo { max-height: 32px; margin-bottom: 4px; } + .wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; } + + /* ── Progressive Forms (v0.35.0) ── */ + .wf-fieldset-nav { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 16px; padding-bottom: 12px; + border-bottom: 1px solid var(--border); + } + .wf-fieldset-nav .step-label { font-size: 14px; font-weight: 600; } + .wf-fieldset-nav .step-counter { font-size: 12px; color: var(--text-3); } + .wf-fieldset-buttons { display: flex; gap: 8px; margin-top: 16px; } + .wf-fieldset-buttons button { + padding: 8px 20px; border-radius: 8px; font-size: 14px; cursor: pointer; + font-weight: 600; border: 1px solid var(--border); + background: var(--bg-surface); color: var(--text); + } + .wf-fieldset-buttons button.primary { + background: var(--accent); color: #fff; border-color: var(--accent); + } + .wf-fieldset-buttons button.primary:hover { background: var(--accent-hover); } + + /* ── Conditional field (hidden) ── */ + .wf-form-field.cond-hidden { display: none; }