diff --git a/CHANGELOG.md b/CHANGELOG.md index fd20ded..028d170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ 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 Promotes the typed form system from a workflow-only model to a reusable diff --git a/ROADMAP.md b/ROADMAP.md index b0b69aa..34970b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,11 +122,12 @@ Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`. 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` -presence. Remove from new manifests, keep parsing for backward compat. -Collapse `stage_mode` from 4 to 3 values: form / delegated / automated. +`stage_type` deprecated (no longer validated, defaults to "simple"). +`stage_mode` collapsed from 4→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** diff --git a/VERSION b/VERSION index b0bb878..85b7c69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.5 +0.9.6 diff --git a/ci/e2e-workflow-handoff.sh b/ci/e2e-workflow-handoff.sh index 4c08cd4..dfa23a9 100755 --- a/ci/e2e-workflow-handoff.sh +++ b/ci/e2e-workflow-handoff.sh @@ -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" \ -d "{ \"name\": \"Team Review\", - \"stage_mode\": \"review\", + \"stage_mode\": \"form\", \"audience\": \"team\", \"ordinal\": 1, \"assignment_team_id\": \"${TEAM_ID}\" diff --git a/ci/e2e-workflow-nochat.sh b/ci/e2e-workflow-nochat.sh index da2e611..124e59a 100755 --- a/ci/e2e-workflow-nochat.sh +++ b/ci/e2e-workflow-nochat.sh @@ -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" \ -d '{ "name": "Manager Review", - "stage_mode": "review", + "stage_mode": "form", "ordinal": 1 }' 2>/dev/null || echo '{"error":"failed"}') diff --git a/packages/bug-report-triage/manifest.json b/packages/bug-report-triage/manifest.json index 84a6f33..582e7d2 100644 --- a/packages/bug-report-triage/manifest.json +++ b/packages/bug-report-triage/manifest.json @@ -19,7 +19,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "public", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -48,7 +47,6 @@ "ordinal": 1, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -71,7 +69,6 @@ "ordinal": 2, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "sla_seconds": 3600, "form_template": { @@ -91,7 +88,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -108,9 +104,8 @@ { "name": "verify", "ordinal": 4, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/packages/content-approval/manifest.json b/packages/content-approval/manifest.json index d417f05..33c11d3 100644 --- a/packages/content-approval/manifest.json +++ b/packages/content-approval/manifest.json @@ -19,7 +19,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -37,9 +36,8 @@ { "name": "review", "ordinal": 1, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "stage_config": { "validation": { @@ -53,7 +51,6 @@ "ordinal": 2, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -76,7 +73,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/packages/employee-onboarding/manifest.json b/packages/employee-onboarding/manifest.json index e068db8..312294e 100644 --- a/packages/employee-onboarding/manifest.json +++ b/packages/employee-onboarding/manifest.json @@ -40,7 +40,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -69,16 +68,14 @@ "ordinal": 1, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "employee-onboarding:on_provision" }, { "name": "manager-signoff", "ordinal": 2, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "stage_config": { "validation": { @@ -93,7 +90,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -113,7 +109,6 @@ "ordinal": 4, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "employee-onboarding:on_welcome" } diff --git a/packages/icd-test-runner/js/crud/team-workflows.js b/packages/icd-test-runner/js/crud/team-workflows.js index 9913c9f..660c30e 100644 --- a/packages/icd-test-runner/js/crud/team-workflows.js +++ b/packages/icd-test-runner/js/crud/team-workflows.js @@ -82,7 +82,7 @@ name: 'Team Intake', ordinal: 0, history_mode: 'full', - stage_mode: 'chat_only' + stage_mode: 'form' }); T.assertShape(d, T.S.workflowStage, 'stage'); T.assert(d.name === 'Team Intake', 'name mismatch'); @@ -94,7 +94,7 @@ name: 'Team Review', ordinal: 1, history_mode: 'summary', - stage_mode: 'review' + stage_mode: 'form' }); T.assertShape(d, T.S.workflowStage, 'stage'); stageIds.push(d.id); @@ -111,7 +111,7 @@ name: 'Team Intake (updated)', ordinal: 0, history_mode: 'full', - stage_mode: 'form_chat', + stage_mode: 'form', form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } }); T.assert(typeof d === 'object', 'expected object'); diff --git a/packages/icd-test-runner/js/crud/workflows.js b/packages/icd-test-runner/js/crud/workflows.js index 55150ad..bdf7e96 100644 --- a/packages/icd-test-runner/js/crud/workflows.js +++ b/packages/icd-test-runner/js/crud/workflows.js @@ -408,7 +408,7 @@ var fwfSlug = testTag + '-form-wf'; 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', { name: testTag + ' Form WF', slug: fwfSlug, @@ -422,7 +422,7 @@ name: 'Contact Info', ordinal: 0, history_mode: 'full', - stage_mode: 'form_only', + stage_mode: 'form', form_template: { fields: [ { key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } }, @@ -436,7 +436,7 @@ }); 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 }); @@ -456,7 +456,7 @@ var d = await T.sessionGet('/w/' + fChannelId + '/form'); T.assert(d._status === 200, 'expected 200, got ' + d._status); 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.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)); @@ -520,7 +520,7 @@ var xwfSlug = testTag + '-xvisitor'; 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', { name: testTag + ' XVisitor', slug: xwfSlug, @@ -530,7 +530,7 @@ T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); }); 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: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } @@ -627,7 +627,7 @@ var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Intake', ordinal: 0, - stage_mode: 'form_only', + stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } }); @@ -637,7 +637,7 @@ var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Review', ordinal: 1, - stage_mode: 'review', + stage_mode: 'form', history_mode: 'summary' }); 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). var realPkgId = 'icd-test-runner'; 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 }] } }; @@ -720,8 +720,8 @@ 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[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[1].stage_mode === 'review', 'stage 1 mode should be review'); + T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form'); + T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form'); }); } diff --git a/packages/webhook-notifier/manifest.json b/packages/webhook-notifier/manifest.json index fc7818c..b6c4bca 100644 --- a/packages/webhook-notifier/manifest.json +++ b/packages/webhook-notifier/manifest.json @@ -30,7 +30,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -50,16 +49,14 @@ "ordinal": 1, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "webhook-notifier:on_fire" }, { "name": "result", "ordinal": 2, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/server/database/migrations/postgres/018_stage_mode_collapse.sql b/server/database/migrations/postgres/018_stage_mode_collapse.sql new file mode 100644 index 0000000..b5a845e --- /dev/null +++ b/server/database/migrations/postgres/018_stage_mode_collapse.sql @@ -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; diff --git a/server/database/migrations/sqlite/018_stage_mode_collapse.sql b/server/database/migrations/sqlite/018_stage_mode_collapse.sql new file mode 100644 index 0000000..53c2842 --- /dev/null +++ b/server/database/migrations/sqlite/018_stage_mode_collapse.sql @@ -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'; diff --git a/server/handlers/workflow_engine_test.go b/server/handlers/workflow_engine_test.go index 3cb1bb0..510e7cf 100644 --- a/server/handlers/workflow_engine_test.go +++ b/server/handlers/workflow_engine_test.go @@ -18,7 +18,7 @@ func testEngine(t *testing.T) *workflow.Engine { 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). func seedEngineFixture(t *testing.T, slug string) (string, string, string) { t.Helper() @@ -42,7 +42,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) { stages := []models.WorkflowStage{ {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}, } for i := range stages { @@ -134,8 +134,8 @@ func TestEngine_BranchRouting(t *testing.T) { 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: 1, Name: "normal-review", StageMode: models.StageModeReview, 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: 1, Name: "normal-review", StageMode: models.StageModeForm, 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 { s.Workflows.CreateStage(ctx, &stages[i]) @@ -184,7 +184,7 @@ func TestEngine_PublicEntry(t *testing.T) { stages := []models.WorkflowStage{ {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 { s.Workflows.CreateStage(ctx, &stages[i]) @@ -253,7 +253,7 @@ func TestEngine_SignoffGate(t *testing.T) { "validation": map[string]any{"required_approvals": 2}, }) 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}, } for i := range stages { @@ -307,7 +307,7 @@ func TestEngine_SignoffRejection(t *testing.T) { "validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"}, }) 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}, } 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: 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: 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 { if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil { diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index 368eb3a..508ebd0 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -55,7 +55,6 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) { "ordinal": s.Ordinal, "stage_mode": s.StageMode, "audience": s.Audience, - "stage_type": s.StageType, "auto_transition": s.AutoTransition, } if s.AssignmentTeamID != nil { @@ -230,6 +229,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st StarlarkHook: s.StarlarkHook, SLASeconds: s.SLASeconds, } + st.StageMode = models.NormalizeStageModeInput(st.StageMode) if st.StageMode == "" { st.StageMode = models.StageModeDelegated } @@ -284,7 +284,7 @@ type workflowPkgStage struct { Ordinal int `json:"ordinal"` StageMode string `json:"stage_mode"` 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"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index d1ccffe..04026b4 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -69,7 +69,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s WorkflowID: wf.ID, Ordinal: 1, Name: "review", - StageMode: models.StageModeReview, + StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID, diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index aa535f7..5e2138c 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -202,11 +202,12 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } + st.StageMode = models.NormalizeStageModeInput(st.StageMode) if st.StageMode == "" { st.StageMode = models.StageModeDelegated } 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 } if st.Audience == "" { @@ -219,18 +220,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { if st.StageType == "" { 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) 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 { existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID) st.Ordinal = len(existing) @@ -252,26 +245,19 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { } st.ID = c.Param("sid") st.WorkflowID = c.Param("id") + st.StageMode = models.NormalizeStageModeInput(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 } if st.Audience != "" && !models.ValidAudiences[st.Audience] { c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"}) 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"}) return diff --git a/server/models/workflow.go b/server/models/workflow.go index 95c297d..0c85204 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -56,9 +56,9 @@ type WorkflowStage struct { Name string `json:"name"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` 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 - StageType string `json:"stage_type"` // simple | dynamic | automated + StageType string `json:"stage_type"` // deprecated — retained for backward compatibility AutoTransition bool `json:"auto_transition"` StageConfig json.RawMessage `json:"stage_config"` BranchRules json.RawMessage `json:"branch_rules"` @@ -72,7 +72,6 @@ type WorkflowStage struct { const ( StageModeForm = "form" - StageModeReview = "review" StageModeDelegated = "delegated" StageModeAutomated = "automated" ) @@ -80,12 +79,22 @@ const ( // ValidStageModes is the set of valid stage_mode values. var ValidStageModes = map[string]bool{ StageModeForm: true, - StageModeReview: true, StageModeDelegated: 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 ( StageTypeSimple = "simple" @@ -93,13 +102,6 @@ const ( StageTypeAutomated = "automated" ) -// ValidStageTypes is the set of valid stage_type values. -var ValidStageTypes = map[string]bool{ - StageTypeSimple: true, - StageTypeDynamic: true, - StageTypeAutomated: true, -} - // ── Audience Constants ────────────────────── const ( diff --git a/server/pages/pages.go b/server/pages/pages.go index deeeeb2..6504983 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -921,7 +921,7 @@ type WorkflowPageData struct { WorkflowDescription string SessionID string SessionName string - StageMode string // form | review | delegated | automated + StageMode string // form | delegated | automated StageName string FormTemplateJSON string // typed form template JSON (empty if delegated) TotalStages int @@ -948,7 +948,7 @@ type WorkflowLandingPageData struct { PersonaName string PersonaIcon string StageCount int - FirstStageMode string // form | review | delegated | automated + FirstStageMode string // form | delegated | automated ResumeURL string // non-empty if visitor has an active session } diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html index e5752e7..f2a0775 100644 --- a/server/pages/templates/workflow-landing.html +++ b/server/pages/templates/workflow-landing.html @@ -154,7 +154,7 @@ {{end}} {{if .Data.ResumeURL}} diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 090bcca..c99c542 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -178,7 +178,7 @@ {{end}} - + {{if .Data.AudienceMismatch}}
@@ -195,11 +195,6 @@
{{else if eq .Data.StageMode "form"}}
- {{else if eq .Data.StageMode "review"}} -
-
-
-
{{else if eq .Data.Status "completed"}}

Completed

@@ -212,7 +207,7 @@ {{end}}
- {{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} {{.Data.SessionName}} + {{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else}}Session:{{end}} {{.Data.SessionName}}
@@ -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 = '
Loading\u2026
'; - - // Left panel: structured data card - var html = '
'; - html += '

Collected Data

'; - - 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 += ''; - for (var key in status.stage_data) { - if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue; - html += ''; - html += ''; - var val = status.stage_data[key]; - var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val); - html += ''; - html += ''; - } - html += '
' + escHtml(key) + '' + escHtml(display) + '
'; - } else { - html += '

No data collected yet.

'; - } - } - } catch(e) { - html += '

Failed to load review data.

'; - } - html += '
'; - dataPanel.innerHTML = html; - - // Right panel: comment input + approve/reject buttons - var actHtml = '
'; - actHtml += '

Review Actions

'; - actHtml += '
'; - actHtml += ''; - actHtml += ''; - actHtml += '
'; - actHtml += '
'; - actHtml += ''; - actHtml += ''; - actHtml += '
'; - actHtml += '
Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject
'; - actHtml += '
'; - 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 = '

Approved

Stage advanced.

'; - 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 = '

Rejected

Sent back for revision.

'; - 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); } - }); - } })(); diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index c163166..0edd378 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -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("stage_mode"), starlark.String(s.StageMode)) 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)) if s.StarlarkHook != nil { d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook)) diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 255eb6e..5592f9a 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -437,13 +437,14 @@ components: type: integer stage_mode: type: string - enum: [form, review, delegated, automated] + enum: [form, delegated, automated] audience: type: string enum: [team, public, system] stage_type: type: string - enum: [simple, dynamic, automated] + deprecated: true + description: Deprecated — retained for backward compatibility. Automation is determined by starlark_hook presence. form_template: type: object stage_config: @@ -1777,7 +1778,7 @@ paths: enum: [full, summary, fresh] stage_mode: type: string - enum: [form, review, delegated, automated] + enum: [form, delegated, automated] form_template: type: object transition_rules: @@ -1817,7 +1818,7 @@ paths: enum: [full, summary, fresh] stage_mode: type: string - enum: [form, review, delegated, automated] + enum: [form, delegated, automated] form_template: type: object transition_rules: @@ -3209,7 +3210,7 @@ paths: enum: [full, summary, fresh] stage_mode: type: string - enum: [form, review, delegated, automated] + enum: [form, delegated, automated] form_template: type: object transition_rules: @@ -3250,7 +3251,7 @@ paths: enum: [full, summary, fresh] stage_mode: type: string - enum: [form, review, delegated, automated] + enum: [form, delegated, automated] form_template: type: object transition_rules: diff --git a/server/store/sqlite/workflows_test.go b/server/store/sqlite/workflows_test.go index 71fe90a..5563661 100644 --- a/server/store/sqlite/workflows_test.go +++ b/server/store/sqlite/workflows_test.go @@ -194,7 +194,7 @@ func TestStageCRUD(t *testing.T) { WorkflowID: wf.ID, Ordinal: 1, Name: "Review", - StageMode: "review", + StageMode: "form", } s.CreateStage(ctx, stage2) @@ -208,7 +208,7 @@ func TestStageCRUD(t *testing.T) { if stages[0].Name != "Intake" || stages[0].StageMode != "form" { 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) } @@ -243,7 +243,7 @@ func TestStageReorder(t *testing.T) { s.Create(ctx, wf) 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, s2) diff --git a/src/js/sw/components/stage-form.js b/src/js/sw/components/stage-form.js index 67f892d..42ba0cb 100644 --- a/src/js/sw/components/stage-form.js +++ b/src/js/sw/components/stage-form.js @@ -13,15 +13,13 @@ const { html } = window; const { useState, useEffect } = hooks; -export const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; -export const STAGE_TYPES = ['simple', 'dynamic', 'automated']; +export const STAGE_MODES = ['form', 'delegated', 'automated']; export const AUDIENCES = ['team', 'public', 'system']; export function StageForm({ stage, teams, onSave, onCancel }) { const [name, setName] = useState(stage?.name || ''); const [mode, setMode] = useState(stage?.stage_mode || 'form'); const [audience, setAudience] = useState(stage?.audience || 'team'); - const [stageType, setStageType] = useState(stage?.stage_type || 'simple'); const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || ''); const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); @@ -60,7 +58,6 @@ export function StageForm({ stage, teams, onSave, onCancel }) { name, stage_mode: mode, audience, - stage_type: stageType, starlark_hook: starlarkHook || null, assignment_team_id: assignTeam || null, auto_transition: autoTransition, @@ -91,14 +88,8 @@ export function StageForm({ stage, teams, onSave, onCancel }) { ${AUDIENCES.map(a => html``)}
-
- - -
- ${stageType !== 'simple' && html` + ${mode === 'automated' && html`