Feat v0.9.6 deprecate stage_type, collapse stage_mode
stage_type no longer validated — starlark_hook presence determines automation. stage_mode collapsed from 4→3 values (form/delegated/ automated); "review" mapped to "form" on input for backward compat. Migration 018 converts existing rows. Review surface removed (~110 lines). 4 package manifests updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.9.6 — Deprecate stage_type, Collapse stage_mode
|
||||||
|
|
||||||
|
Simplifies the workflow stage classification model by removing
|
||||||
|
redundant fields.
|
||||||
|
|
||||||
|
**stage_type deprecated**
|
||||||
|
|
||||||
|
- No longer validated on input; any value accepted, defaults to "simple"
|
||||||
|
- Existing manifests parsed for backward compatibility
|
||||||
|
- DB column retained; export no longer includes the field
|
||||||
|
- `starlark_hook` presence (not `stage_type`) determines automation
|
||||||
|
|
||||||
|
**stage_mode collapsed (4 → 3 values)**
|
||||||
|
|
||||||
|
- "review" removed as valid mode; mapped to "form" on input
|
||||||
|
- Existing DB rows migrated: review → form
|
||||||
|
- Review surface removed from workflow.html (~110 lines); signoff system
|
||||||
|
in `stage_config.validation` handles review behavior
|
||||||
|
- Valid modes: form, delegated, automated
|
||||||
|
|
||||||
|
**DB migration 018**
|
||||||
|
|
||||||
|
- Postgres: UPDATE + CHECK constraint replacement
|
||||||
|
- SQLite: UPDATE only (CHECK stays broad)
|
||||||
|
|
||||||
|
**Package manifests updated**
|
||||||
|
|
||||||
|
- bug-report-triage, content-approval, employee-onboarding,
|
||||||
|
webhook-notifier: review → form, stage_type removed
|
||||||
|
|
||||||
## v0.9.5 — Typed Forms → SDK Primitive
|
## v0.9.5 — Typed Forms → SDK Primitive
|
||||||
|
|
||||||
Promotes the typed form system from a workflow-only model to a reusable
|
Promotes the typed form system from a workflow-only model to a reusable
|
||||||
|
|||||||
@@ -122,11 +122,12 @@ Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
|||||||
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
|
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
|
||||||
Manifest `form_template` accepted at package level. 16 new tests.
|
Manifest `form_template` accepted at package level. 16 new tests.
|
||||||
|
|
||||||
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`**
|
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)*
|
||||||
|
|
||||||
`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook`
|
`stage_type` deprecated (no longer validated, defaults to "simple").
|
||||||
presence. Remove from new manifests, keep parsing for backward compat.
|
`stage_mode` collapsed from 4→3 values: form / delegated / automated.
|
||||||
Collapse `stage_mode` from 4 to 3 values: form / delegated / automated.
|
"review" mapped to "form" on input; review surface removed (~110 lines).
|
||||||
|
Migration 018. 4 package manifests updated.
|
||||||
|
|
||||||
**v0.9.7 — Full Read/Write Workflow Starlark Module**
|
**v0.9.7 — Full Read/Write Workflow Starlark Module**
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
|||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"name\": \"Team Review\",
|
\"name\": \"Team Review\",
|
||||||
\"stage_mode\": \"review\",
|
\"stage_mode\": \"form\",
|
||||||
\"audience\": \"team\",
|
\"audience\": \"team\",
|
||||||
\"ordinal\": 1,
|
\"ordinal\": 1,
|
||||||
\"assignment_team_id\": \"${TEAM_ID}\"
|
\"assignment_team_id\": \"${TEAM_ID}\"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
|||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Manager Review",
|
"name": "Manager Review",
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"ordinal": 1
|
"ordinal": 1
|
||||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "public",
|
"audience": "public",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -71,7 +69,6 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"sla_seconds": 3600,
|
"sla_seconds": 3600,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
@@ -91,7 +88,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -108,9 +104,8 @@
|
|||||||
{
|
{
|
||||||
"name": "verify",
|
"name": "verify",
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -37,9 +36,8 @@
|
|||||||
{
|
{
|
||||||
"name": "review",
|
"name": "review",
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"stage_config": {
|
"stage_config": {
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -76,7 +73,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -69,16 +68,14 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "employee-onboarding:on_provision"
|
"starlark_hook": "employee-onboarding:on_provision"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "manager-signoff",
|
"name": "manager-signoff",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"stage_config": {
|
"stage_config": {
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -93,7 +90,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -113,7 +109,6 @@
|
|||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "employee-onboarding:on_welcome"
|
"starlark_hook": "employee-onboarding:on_welcome"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
name: 'Team Intake',
|
name: 'Team Intake',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'chat_only'
|
stage_mode: 'form'
|
||||||
});
|
});
|
||||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||||
T.assert(d.name === 'Team Intake', 'name mismatch');
|
T.assert(d.name === 'Team Intake', 'name mismatch');
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
name: 'Team Review',
|
name: 'Team Review',
|
||||||
ordinal: 1,
|
ordinal: 1,
|
||||||
history_mode: 'summary',
|
history_mode: 'summary',
|
||||||
stage_mode: 'review'
|
stage_mode: 'form'
|
||||||
});
|
});
|
||||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||||
stageIds.push(d.id);
|
stageIds.push(d.id);
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
name: 'Team Intake (updated)',
|
name: 'Team Intake (updated)',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'form_chat',
|
stage_mode: 'form',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
|
||||||
});
|
});
|
||||||
T.assert(typeof d === 'object', 'expected object');
|
T.assert(typeof d === 'object', 'expected object');
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
var fwfSlug = testTag + '-form-wf';
|
var fwfSlug = testTag + '-form-wf';
|
||||||
var fChannelId = null;
|
var fChannelId = null;
|
||||||
|
|
||||||
await T.test('crud', 'workflows', 'form: create form_only workflow', async function () {
|
await T.test('crud', 'workflows', 'form: create form workflow', async function () {
|
||||||
var wf = await T.apiPost('/workflows', {
|
var wf = await T.apiPost('/workflows', {
|
||||||
name: testTag + ' Form WF',
|
name: testTag + ' Form WF',
|
||||||
slug: fwfSlug,
|
slug: fwfSlug,
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
name: 'Contact Info',
|
name: 'Contact Info',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'form_only',
|
stage_mode: 'form',
|
||||||
form_template: {
|
form_template: {
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
|
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
await T.apiPost('/workflows/' + fwfId + '/stages', {
|
await T.apiPost('/workflows/' + fwfId + '/stages', {
|
||||||
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'chat_only'
|
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form'
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
|
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
var d = await T.sessionGet('/w/' + fChannelId + '/form');
|
var d = await T.sessionGet('/w/' + fChannelId + '/form');
|
||||||
T.assert(d._status === 200, 'expected 200, got ' + d._status);
|
T.assert(d._status === 200, 'expected 200, got ' + d._status);
|
||||||
T.assertHasKey(d, 'stage_mode', 'form response');
|
T.assertHasKey(d, 'stage_mode', 'form response');
|
||||||
T.assert(d.stage_mode === 'form_only', 'expected form_only, got ' + d.stage_mode);
|
T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode);
|
||||||
T.assertHasKey(d, 'form_template', 'form response');
|
T.assertHasKey(d, 'form_template', 'form response');
|
||||||
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
|
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
|
||||||
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
|
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
var xwfSlug = testTag + '-xvisitor';
|
var xwfSlug = testTag + '-xvisitor';
|
||||||
var chA = null, chB = null;
|
var chA = null, chB = null;
|
||||||
|
|
||||||
await T.test('crud', 'workflows', 'xvisitor: setup form_only workflow', async function () {
|
await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () {
|
||||||
var wf = await T.apiPost('/workflows', {
|
var wf = await T.apiPost('/workflows', {
|
||||||
name: testTag + ' XVisitor',
|
name: testTag + ' XVisitor',
|
||||||
slug: xwfSlug,
|
slug: xwfSlug,
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
|
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
|
||||||
|
|
||||||
await T.apiPost('/workflows/' + xwfId + '/stages', {
|
await T.apiPost('/workflows/' + xwfId + '/stages', {
|
||||||
name: 'Form', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||||
form_template: {
|
form_template: {
|
||||||
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
|
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
|
||||||
}
|
}
|
||||||
@@ -627,7 +627,7 @@
|
|||||||
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||||
name: 'Intake',
|
name: 'Intake',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
stage_mode: 'form_only',
|
stage_mode: 'form',
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||||
});
|
});
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||||
name: 'Review',
|
name: 'Review',
|
||||||
ordinal: 1,
|
ordinal: 1,
|
||||||
stage_mode: 'review',
|
stage_mode: 'form',
|
||||||
history_mode: 'summary'
|
history_mode: 'summary'
|
||||||
});
|
});
|
||||||
T.assertShape(s2, T.S.workflowStage, 'stage2');
|
T.assertShape(s2, T.S.workflowStage, 'stage2');
|
||||||
@@ -650,7 +650,7 @@
|
|||||||
// Use the ICD test runner's own package ID (always installed when tests run).
|
// Use the ICD test runner's own package ID (always installed when tests run).
|
||||||
var realPkgId = 'icd-test-runner';
|
var realPkgId = 'icd-test-runner';
|
||||||
var stageBase = {
|
var stageBase = {
|
||||||
name: 'Intake', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -720,8 +720,8 @@
|
|||||||
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
|
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
|
||||||
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
|
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
|
||||||
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
|
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
|
||||||
T.assert(stList[0].stage_mode === 'form_only', 'stage 0 mode should be form_only');
|
T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form');
|
||||||
T.assert(stList[1].stage_mode === 'review', 'stage 1 mode should be review');
|
T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -50,16 +49,14 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "webhook-notifier:on_fire"
|
"starlark_hook": "webhook-notifier:on_fire"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "result",
|
"name": "result",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Armature — 018 Stage Mode Collapse
|
||||||
|
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||||
|
|
||||||
|
-- 1. Migrate existing review rows to form
|
||||||
|
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||||
|
|
||||||
|
-- 2. Replace CHECK constraint on stage_mode (drop review)
|
||||||
|
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_mode_check;
|
||||||
|
ALTER TABLE workflow_stages ADD CONSTRAINT workflow_stages_stage_mode_check
|
||||||
|
CHECK (stage_mode IN ('form', 'delegated', 'automated'));
|
||||||
|
|
||||||
|
-- 3. Drop CHECK constraint on stage_type (any value accepted)
|
||||||
|
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_type_check;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Armature — 018 Stage Mode Collapse
|
||||||
|
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||||
|
-- SQLite cannot ALTER CHECK constraints; app layer prevents new "review" inserts.
|
||||||
|
|
||||||
|
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||||
@@ -18,7 +18,7 @@ func testEngine(t *testing.T) *workflow.Engine {
|
|||||||
return workflow.NewEngine(s, nil, nil)
|
return workflow.NewEngine(s, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedEngineFixture creates a 3-stage workflow (form → review → form),
|
// seedEngineFixture creates a 3-stage workflow (form → form → form),
|
||||||
// publishes it, and returns (workflowID, userID, teamID).
|
// publishes it, and returns (workflowID, userID, teamID).
|
||||||
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -42,7 +42,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
@@ -134,8 +134,8 @@ func TestEngine_BranchRouting(t *testing.T) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
@@ -184,7 +184,7 @@ func TestEngine_PublicEntry(t *testing.T) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
@@ -253,7 +253,7 @@ func TestEngine_SignoffGate(t *testing.T) {
|
|||||||
"validation": map[string]any{"required_approvals": 2},
|
"validation": map[string]any{"required_approvals": 2},
|
||||||
})
|
})
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
@@ -307,7 +307,7 @@ func TestEngine_SignoffRejection(t *testing.T) {
|
|||||||
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
||||||
})
|
})
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
@@ -590,7 +590,7 @@ func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
|
|||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
||||||
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
|||||||
"ordinal": s.Ordinal,
|
"ordinal": s.Ordinal,
|
||||||
"stage_mode": s.StageMode,
|
"stage_mode": s.StageMode,
|
||||||
"audience": s.Audience,
|
"audience": s.Audience,
|
||||||
"stage_type": s.StageType,
|
|
||||||
"auto_transition": s.AutoTransition,
|
"auto_transition": s.AutoTransition,
|
||||||
}
|
}
|
||||||
if s.AssignmentTeamID != nil {
|
if s.AssignmentTeamID != nil {
|
||||||
@@ -230,6 +229,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
|||||||
StarlarkHook: s.StarlarkHook,
|
StarlarkHook: s.StarlarkHook,
|
||||||
SLASeconds: s.SLASeconds,
|
SLASeconds: s.SLASeconds,
|
||||||
}
|
}
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode == "" {
|
if st.StageMode == "" {
|
||||||
st.StageMode = models.StageModeDelegated
|
st.StageMode = models.StageModeDelegated
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ type workflowPkgStage struct {
|
|||||||
Ordinal int `json:"ordinal"`
|
Ordinal int `json:"ordinal"`
|
||||||
StageMode string `json:"stage_mode"`
|
StageMode string `json:"stage_mode"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
StageType string `json:"stage_type"`
|
StageType string `json:"stage_type"` // deprecated — kept for backward-compat deserialization
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s
|
|||||||
WorkflowID: wf.ID,
|
WorkflowID: wf.ID,
|
||||||
Ordinal: 1,
|
Ordinal: 1,
|
||||||
Name: "review",
|
Name: "review",
|
||||||
StageMode: models.StageModeReview,
|
StageMode: models.StageModeForm,
|
||||||
Audience: models.AudienceTeam,
|
Audience: models.AudienceTeam,
|
||||||
StageType: models.StageTypeSimple,
|
StageType: models.StageTypeSimple,
|
||||||
AssignmentTeamID: &teamID,
|
AssignmentTeamID: &teamID,
|
||||||
|
|||||||
@@ -202,11 +202,12 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode == "" {
|
if st.StageMode == "" {
|
||||||
st.StageMode = models.StageModeDelegated
|
st.StageMode = models.StageModeDelegated
|
||||||
}
|
}
|
||||||
if !models.ValidStageModes[st.StageMode] {
|
if !models.ValidStageModes[st.StageMode] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.Audience == "" {
|
if st.Audience == "" {
|
||||||
@@ -219,18 +220,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|||||||
if st.StageType == "" {
|
if st.StageType == "" {
|
||||||
st.StageType = models.StageTypeSimple
|
st.StageType = models.StageTypeSimple
|
||||||
}
|
}
|
||||||
if !models.ValidStageTypes[st.StageType] {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.Ordinal == 0 {
|
if st.Ordinal == 0 {
|
||||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||||
st.Ordinal = len(existing)
|
st.Ordinal = len(existing)
|
||||||
@@ -252,26 +245,19 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
st.ID = c.Param("sid")
|
st.ID = c.Param("sid")
|
||||||
st.WorkflowID = c.Param("id")
|
st.WorkflowID = c.Param("id")
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.StageType != "" && !models.ValidStageTypes[st.StageType] {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ type WorkflowStage struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
FormTemplate json.RawMessage `json:"form_template"`
|
FormTemplate json.RawMessage `json:"form_template"`
|
||||||
StageMode string `json:"stage_mode"` // form | review | delegated | automated
|
StageMode string `json:"stage_mode"` // form | delegated | automated
|
||||||
Audience string `json:"audience"` // team | public | system
|
Audience string `json:"audience"` // team | public | system
|
||||||
StageType string `json:"stage_type"` // simple | dynamic | automated
|
StageType string `json:"stage_type"` // deprecated — retained for backward compatibility
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
StageConfig json.RawMessage `json:"stage_config"`
|
StageConfig json.RawMessage `json:"stage_config"`
|
||||||
BranchRules json.RawMessage `json:"branch_rules"`
|
BranchRules json.RawMessage `json:"branch_rules"`
|
||||||
@@ -72,7 +72,6 @@ type WorkflowStage struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StageModeForm = "form"
|
StageModeForm = "form"
|
||||||
StageModeReview = "review"
|
|
||||||
StageModeDelegated = "delegated"
|
StageModeDelegated = "delegated"
|
||||||
StageModeAutomated = "automated"
|
StageModeAutomated = "automated"
|
||||||
)
|
)
|
||||||
@@ -80,12 +79,22 @@ const (
|
|||||||
// ValidStageModes is the set of valid stage_mode values.
|
// ValidStageModes is the set of valid stage_mode values.
|
||||||
var ValidStageModes = map[string]bool{
|
var ValidStageModes = map[string]bool{
|
||||||
StageModeForm: true,
|
StageModeForm: true,
|
||||||
StageModeReview: true,
|
|
||||||
StageModeDelegated: true,
|
StageModeDelegated: true,
|
||||||
StageModeAutomated: true,
|
StageModeAutomated: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage Type Constants ────────────────────
|
// NormalizeStageModeInput maps deprecated stage_mode values to their
|
||||||
|
// replacements. "review" → "form"; all others pass through unchanged.
|
||||||
|
func NormalizeStageModeInput(mode string) string {
|
||||||
|
if mode == "review" {
|
||||||
|
return StageModeForm
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage Type Constants (deprecated) ───────
|
||||||
|
// stage_type is redundant with starlark_hook presence and is no longer
|
||||||
|
// validated. Constants are retained for backward-compatible references.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StageTypeSimple = "simple"
|
StageTypeSimple = "simple"
|
||||||
@@ -93,13 +102,6 @@ const (
|
|||||||
StageTypeAutomated = "automated"
|
StageTypeAutomated = "automated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidStageTypes is the set of valid stage_type values.
|
|
||||||
var ValidStageTypes = map[string]bool{
|
|
||||||
StageTypeSimple: true,
|
|
||||||
StageTypeDynamic: true,
|
|
||||||
StageTypeAutomated: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audience Constants ──────────────────────
|
// ── Audience Constants ──────────────────────
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -921,7 +921,7 @@ type WorkflowPageData struct {
|
|||||||
WorkflowDescription string
|
WorkflowDescription string
|
||||||
SessionID string
|
SessionID string
|
||||||
SessionName string
|
SessionName string
|
||||||
StageMode string // form | review | delegated | automated
|
StageMode string // form | delegated | automated
|
||||||
StageName string
|
StageName string
|
||||||
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
||||||
TotalStages int
|
TotalStages int
|
||||||
@@ -948,7 +948,7 @@ type WorkflowLandingPageData struct {
|
|||||||
PersonaName string
|
PersonaName string
|
||||||
PersonaIcon string
|
PersonaIcon string
|
||||||
StageCount int
|
StageCount int
|
||||||
FirstStageMode string // form | review | delegated | automated
|
FirstStageMode string // form | delegated | automated
|
||||||
ResumeURL string // non-empty if visitor has an active session
|
ResumeURL string // non-empty if visitor has an active session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||||
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else if eq .Data.FirstStageMode "review"}}Start Review{{else}}Start{{end}}
|
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else}}Start{{end}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{if .Data.ResumeURL}}
|
{{if .Data.ResumeURL}}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
<!-- Stage surface: form, delegated (custom surface), or automated -->
|
||||||
|
|
||||||
{{if .Data.AudienceMismatch}}
|
{{if .Data.AudienceMismatch}}
|
||||||
<div class="wf-submitted">
|
<div class="wf-submitted">
|
||||||
@@ -195,11 +195,6 @@
|
|||||||
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||||
{{else if eq .Data.StageMode "form"}}
|
{{else if eq .Data.StageMode "form"}}
|
||||||
<div class="wf-form" id="formArea"></div>
|
<div class="wf-form" id="formArea"></div>
|
||||||
{{else if eq .Data.StageMode "review"}}
|
|
||||||
<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 if eq .Data.Status "completed"}}
|
{{else if eq .Data.Status "completed"}}
|
||||||
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
||||||
<h3>Completed</h3>
|
<h3>Completed</h3>
|
||||||
@@ -212,7 +207,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="wf-session-info">
|
<div class="wf-session-info">
|
||||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -542,116 +537,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Review surface (structured review with side-by-side + comments) ──
|
|
||||||
if (STAGE_MODE === 'review') {
|
|
||||||
var dataPanel = document.getElementById('reviewDataPanel');
|
|
||||||
var actionPanel = document.getElementById('reviewActionPanel');
|
|
||||||
if (dataPanel && actionPanel) {
|
|
||||||
loadReviewSurface(dataPanel, actionPanel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
|
||||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
|
||||||
|
|
||||||
// Left panel: structured data card
|
|
||||||
var html = '<div style="padding:24px">';
|
|
||||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + API_ID, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
var status = await resp.json();
|
|
||||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
|
||||||
html += '<table style="width:100%;border-collapse:collapse">';
|
|
||||||
for (var key in status.stage_data) {
|
|
||||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
|
||||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
|
||||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
|
||||||
var val = status.stage_data[key];
|
|
||||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
|
||||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
html += '</table>';
|
|
||||||
} else {
|
|
||||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
dataPanel.innerHTML = html;
|
|
||||||
|
|
||||||
// Right panel: comment input + approve/reject buttons
|
|
||||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
|
||||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
|
||||||
actHtml += '<div style="margin-bottom:16px">';
|
|
||||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
|
||||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actHtml += '<div style="display:flex;gap:8px">';
|
|
||||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
|
||||||
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject</div>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actionPanel.innerHTML = actHtml;
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.ctrlKey && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
document.getElementById('reviewRejectBtn').click();
|
|
||||||
} else {
|
|
||||||
document.getElementById('reviewAdvanceBtn').click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
|
||||||
this.disabled = true;
|
|
||||||
try {
|
|
||||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: {} }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
|
||||||
setTimeout(function() { window.location.reload(); }, 1500);
|
|
||||||
} else {
|
|
||||||
var err = await r.json().catch(function() { return {}; });
|
|
||||||
alert('Failed: ' + (err.error || 'unknown'));
|
|
||||||
document.getElementById('reviewAdvanceBtn').disabled = false;
|
|
||||||
}
|
|
||||||
} catch(e) { alert('Error: ' + e.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
|
||||||
var comment = document.getElementById('reviewComment').value;
|
|
||||||
var reason = comment || prompt('Rejection reason:');
|
|
||||||
if (!reason) return;
|
|
||||||
this.disabled = true;
|
|
||||||
try {
|
|
||||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
|
||||||
setTimeout(function() { window.location.reload(); }, 1500);
|
|
||||||
} else {
|
|
||||||
var err = await r.json().catch(function() { return {}; });
|
|
||||||
alert('Failed: ' + (err.error || 'unknown'));
|
|
||||||
document.getElementById('reviewRejectBtn').disabled = false;
|
|
||||||
}
|
|
||||||
} catch(e) { alert('Error: ' + e.message); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
|||||||
d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal))
|
d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal))
|
||||||
d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode))
|
d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode))
|
||||||
d.SetKey(starlark.String("audience"), starlark.String(s.Audience))
|
d.SetKey(starlark.String("audience"), starlark.String(s.Audience))
|
||||||
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType))
|
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType)) // deprecated — kept for backward compat
|
||||||
d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition))
|
d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition))
|
||||||
if s.StarlarkHook != nil {
|
if s.StarlarkHook != nil {
|
||||||
d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook))
|
d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook))
|
||||||
|
|||||||
@@ -437,13 +437,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
audience:
|
audience:
|
||||||
type: string
|
type: string
|
||||||
enum: [team, public, system]
|
enum: [team, public, system]
|
||||||
stage_type:
|
stage_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [simple, dynamic, automated]
|
deprecated: true
|
||||||
|
description: Deprecated — retained for backward compatibility. Automation is determined by starlark_hook presence.
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
stage_config:
|
stage_config:
|
||||||
@@ -1777,7 +1778,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -1817,7 +1818,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -3209,7 +3210,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -3250,7 +3251,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func TestStageCRUD(t *testing.T) {
|
|||||||
WorkflowID: wf.ID,
|
WorkflowID: wf.ID,
|
||||||
Ordinal: 1,
|
Ordinal: 1,
|
||||||
Name: "Review",
|
Name: "Review",
|
||||||
StageMode: "review",
|
StageMode: "form",
|
||||||
}
|
}
|
||||||
s.CreateStage(ctx, stage2)
|
s.CreateStage(ctx, stage2)
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ func TestStageCRUD(t *testing.T) {
|
|||||||
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
||||||
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
||||||
}
|
}
|
||||||
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
|
if stages[1].Name != "Review" || stages[1].StageMode != "form" {
|
||||||
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestStageReorder(t *testing.T) {
|
|||||||
s.Create(ctx, wf)
|
s.Create(ctx, wf)
|
||||||
|
|
||||||
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
||||||
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
|
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "form"}
|
||||||
s.CreateStage(ctx, s1)
|
s.CreateStage(ctx, s1)
|
||||||
s.CreateStage(ctx, s2)
|
s.CreateStage(ctx, s2)
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,13 @@
|
|||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect } = hooks;
|
const { useState, useEffect } = hooks;
|
||||||
|
|
||||||
export const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
export const STAGE_MODES = ['form', 'delegated', 'automated'];
|
||||||
export const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
|
||||||
export const AUDIENCES = ['team', 'public', 'system'];
|
export const AUDIENCES = ['team', 'public', 'system'];
|
||||||
|
|
||||||
export function StageForm({ stage, teams, onSave, onCancel }) {
|
export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||||
const [name, setName] = useState(stage?.name || '');
|
const [name, setName] = useState(stage?.name || '');
|
||||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
|
||||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||||
@@ -60,7 +58,6 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
|||||||
name,
|
name,
|
||||||
stage_mode: mode,
|
stage_mode: mode,
|
||||||
audience,
|
audience,
|
||||||
stage_type: stageType,
|
|
||||||
starlark_hook: starlarkHook || null,
|
starlark_hook: starlarkHook || null,
|
||||||
assignment_team_id: assignTeam || null,
|
assignment_team_id: assignTeam || null,
|
||||||
auto_transition: autoTransition,
|
auto_transition: autoTransition,
|
||||||
@@ -91,14 +88,8 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
|||||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Stage Type</label>
|
|
||||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
|
||||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
${stageType !== 'simple' && html`
|
${mode === 'automated' && html`
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:1;">
|
<div class="form-group" style="flex:1;">
|
||||||
<label>Starlark Hook</label>
|
<label>Starlark Hook</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user