Feat v0.3.6 demo workflows + interactive demo
Four example workflow packages proving the engine end-to-end: - Bug Report Triage: public entry, branch routing, SLA timer - Employee Onboarding: Starlark automated stages, signoff gate - Content Approval: multi-party signoff, revision cycle loop - Webhook Notifier: http.post, connections fallback, delivery logging Demo surface at /s/workflow-demo with cards, stage diagrams, Starlark viewer, API examples, active/published status, and copyable public links. Engine fixes: started_by in automated context, sla_seconds in package installer, parseSnapshotStages for wrapped/legacy formats. Platform fixes: extension SDK boot in base.html (Preact globals + boot()), admin teams paginated response extraction, workflow adoption endpoint (POST /teams/:teamId/workflows/:id/adopt), team-admin copyable link. Review pass remains on roadmap for remaining UI polish. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -468,3 +468,162 @@ func TestEngine_ErrorCases(t *testing.T) {
|
||||
t.Fatalf("error = %q, want 'public entry'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Automated Stage Context (started_by) ────
|
||||
|
||||
func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "AutoCtx", "autoctx@test.com")
|
||||
teamID := database.SeedTestTeam(t, "AutoCtx Team", userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "AutoCtx WF", Slug: "autoctx-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
hook := "test-pkg:on_run"
|
||||
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: "auto-stage", StageMode: models.StageModeAutomated, Audience: models.AudienceSystem, StageType: models.StageTypeAutomated, AutoTransition: true, StarlarkHook: &hook},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
}
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||
|
||||
// Engine with no runner — automated stage silently skips, instance stays at auto-stage
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
inst, err := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
// Advance intake → auto-stage
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance to auto: %v", err)
|
||||
}
|
||||
|
||||
// Verify the instance records started_by
|
||||
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
|
||||
if got.StartedBy != userID {
|
||||
t.Fatalf("started_by = %q, want %q", got.StartedBy, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SLA Seconds in Package Install ──────────
|
||||
|
||||
func TestPackageInstall_SLASeconds(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "SLAPkg", "slapkg@test.com")
|
||||
teamID := database.SeedTestTeam(t, "SLA Team", userID)
|
||||
|
||||
// Simulate workflow package install with sla_seconds
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "SLA WF", Slug: "sla-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
sla := 3600
|
||||
stage := &models.WorkflowStage{
|
||||
WorkflowID: wf.ID, Ordinal: 0, Name: "critical-fix",
|
||||
StageMode: models.StageModeForm, Audience: models.AudienceTeam,
|
||||
StageType: models.StageTypeSimple, SLASeconds: &sla,
|
||||
}
|
||||
if err := s.Workflows.CreateStage(ctx, stage); err != nil {
|
||||
t.Fatalf("create stage: %v", err)
|
||||
}
|
||||
|
||||
// Read it back
|
||||
stages, err := s.Workflows.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list stages: %v", err)
|
||||
}
|
||||
if len(stages) != 1 {
|
||||
t.Fatalf("got %d stages, want 1", len(stages))
|
||||
}
|
||||
if stages[0].SLASeconds == nil || *stages[0].SLASeconds != 3600 {
|
||||
t.Fatalf("sla_seconds = %v, want 3600", stages[0].SLASeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Package Manifest Install ────────
|
||||
|
||||
func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "PkgInst", "pkginst@test.com")
|
||||
_ = database.SeedTestTeam(t, "PkgInst Team", userID)
|
||||
|
||||
// Create a workflow manually matching bug-report-triage structure
|
||||
wf := &models.Workflow{
|
||||
Name: "Bug Report Triage", Slug: "bug-report-triage-test",
|
||||
EntryMode: "public_link", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
if err := s.Workflows.Create(ctx, wf); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
|
||||
sla := 3600
|
||||
branchRules, _ := json.Marshal([]map[string]any{
|
||||
{"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"},
|
||||
{"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"},
|
||||
})
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "submit", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||
{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},
|
||||
}
|
||||
for i := range stages {
|
||||
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
||||
t.Fatalf("create stage %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all stages
|
||||
got, err := s.Workflows.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list stages: %v", err)
|
||||
}
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("got %d stages, want 5", len(got))
|
||||
}
|
||||
|
||||
// Verify branch rules on classify
|
||||
for _, g := range got {
|
||||
if g.Name == "classify" {
|
||||
if len(g.BranchRules) == 0 {
|
||||
t.Fatal("classify missing branch_rules")
|
||||
}
|
||||
if !strings.Contains(string(g.BranchRules), "fix-critical") {
|
||||
t.Fatalf("branch_rules missing fix-critical target: %s", string(g.BranchRules))
|
||||
}
|
||||
}
|
||||
if g.Name == "fix-critical" {
|
||||
if g.SLASeconds == nil || *g.SLASeconds != 3600 {
|
||||
t.Fatalf("fix-critical sla_seconds = %v, want 3600", g.SLASeconds)
|
||||
}
|
||||
}
|
||||
if g.Name == "submit" && g.Audience != models.AudiencePublic {
|
||||
t.Fatalf("submit audience = %q, want public", g.Audience)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
||||
if s.StarlarkHook != nil {
|
||||
sd["starlark_hook"] = *s.StarlarkHook
|
||||
}
|
||||
if s.SLASeconds != nil {
|
||||
sd["sla_seconds"] = *s.SLASeconds
|
||||
}
|
||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
||||
var ft any
|
||||
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
||||
@@ -225,6 +228,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
AssignmentTeamID: s.AssignmentTeamID,
|
||||
SurfacePkgID: s.SurfacePkgID,
|
||||
StarlarkHook: s.StarlarkHook,
|
||||
SLASeconds: s.SLASeconds,
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeDelegated
|
||||
@@ -268,6 +272,7 @@ type workflowPkgStage struct {
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
StarlarkHook *string `json:"starlark_hook,omitempty"`
|
||||
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||
FormTemplate any `json:"form_template,omitempty"`
|
||||
StageConfig any `json:"stage_config,omitempty"`
|
||||
BranchRules any `json:"branch_rules,omitempty"`
|
||||
|
||||
@@ -37,6 +37,53 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Adopt Global Workflow ────────────────────
|
||||
|
||||
// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team.
|
||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
wfID := c.Param("id")
|
||||
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.TeamID != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.WorkflowPatch{TeamID: &teamID}
|
||||
if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the updated workflow
|
||||
c.Set("id", wfID)
|
||||
h.Get(c)
|
||||
}
|
||||
|
||||
// ListGlobalWorkflows returns unowned workflows available for adoption.
|
||||
// GET /api/v1/teams/:teamId/workflows/available
|
||||
func (h *WorkflowHandler) ListGlobalWorkflows(c *gin.Context) {
|
||||
result, err := h.stores.Workflows.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
result = []models.Workflow{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────
|
||||
|
||||
// ListTeamWorkflows returns workflows owned by the team.
|
||||
|
||||
@@ -562,6 +562,8 @@ func main() {
|
||||
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
||||
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
||||
teamScoped.POST("/workflows/:id/clone", teamWfH.CloneTeamWorkflow)
|
||||
teamScoped.POST("/workflows/:id/adopt", teamWfH.AdoptTeamWorkflow)
|
||||
teamScoped.GET("/workflows/available", teamWfH.ListGlobalWorkflows)
|
||||
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||
|
||||
// Team workflow instances + assignments (v0.3.2)
|
||||
|
||||
@@ -38,6 +38,7 @@ type Workflow struct {
|
||||
type WorkflowPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
TeamID *string `json:"team_id,omitempty"`
|
||||
Branding *json.RawMessage `json:"branding,omitempty"`
|
||||
EntryMode *string `json:"entry_mode,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
|
||||
@@ -124,9 +124,20 @@
|
||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
|
||||
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
||||
{{/* v0.27.0: Extension surface JS — load Preact globals, boot SDK, then extension */}}
|
||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}">
|
||||
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||
window.preact = window.preact || { h, render };
|
||||
window.hooks = window.hooks || hooksModule;
|
||||
window.html = window.html || htm.bind(h);
|
||||
|
||||
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||
await boot();
|
||||
await import('{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}');
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
||||
|
||||
@@ -115,6 +115,9 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
||||
if patch.Description != nil {
|
||||
add("description", *patch.Description)
|
||||
}
|
||||
if patch.TeamID != nil {
|
||||
add("team_id", *patch.TeamID)
|
||||
}
|
||||
if patch.Branding != nil {
|
||||
add("branding", string(*patch.Branding))
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
||||
sets = append(sets, "description = ?")
|
||||
args = append(args, *patch.Description)
|
||||
}
|
||||
if patch.TeamID != nil {
|
||||
sets = append(sets, "team_id = ?")
|
||||
args = append(args, *patch.TeamID)
|
||||
}
|
||||
if patch.Branding != nil {
|
||||
sets = append(sets, "branding = ?")
|
||||
args = append(args, string(*patch.Branding))
|
||||
|
||||
@@ -73,10 +73,11 @@ func (e *Engine) processAutomatedStage(ctx context.Context, inst *models.Workflo
|
||||
}
|
||||
|
||||
// Build context dict for the hook
|
||||
ctxDict := starlark.NewDict(4)
|
||||
ctxDict := starlark.NewDict(5)
|
||||
_ = ctxDict.SetKey(starlark.String("instance_id"), starlark.String(inst.ID))
|
||||
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
|
||||
_ = ctxDict.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID))
|
||||
_ = ctxDict.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy))
|
||||
|
||||
// Parse stage_data into Starlark dict
|
||||
var dataMap map[string]interface{}
|
||||
|
||||
@@ -18,6 +18,25 @@ import (
|
||||
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
|
||||
const MaxConsecutiveAutomated = 10
|
||||
|
||||
// parseSnapshotStages handles both snapshot formats:
|
||||
// - Wrapped: {"stages": [...], "workflow": {...}} (from Publish handler)
|
||||
// - Legacy: [...] (from early tests)
|
||||
func parseSnapshotStages(raw json.RawMessage) ([]models.WorkflowStage, error) {
|
||||
// Try wrapped format first
|
||||
var wrapped struct {
|
||||
Stages []models.WorkflowStage `json:"stages"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
||||
return wrapped.Stages, nil
|
||||
}
|
||||
// Fallback to flat array
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(raw, &stages); err != nil {
|
||||
return nil, fmt.Errorf("corrupt version snapshot: %w", err)
|
||||
}
|
||||
return stages, nil
|
||||
}
|
||||
|
||||
// Engine orchestrates workflow instance lifecycle.
|
||||
type Engine struct {
|
||||
stores store.Stores
|
||||
@@ -45,9 +64,9 @@ func (e *Engine) Start(ctx context.Context, workflowID string, initialData json.
|
||||
return nil, fmt.Errorf("no published version: %w", err)
|
||||
}
|
||||
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
||||
return nil, fmt.Errorf("corrupt version snapshot: %w", err)
|
||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
return nil, fmt.Errorf("workflow has no stages")
|
||||
@@ -121,9 +140,9 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
|
||||
return nil, fmt.Errorf("version not found: %w", err)
|
||||
}
|
||||
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find current stage ordinal
|
||||
@@ -321,9 +340,9 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("version not found: %w", err)
|
||||
}
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find current stage and validate audience
|
||||
@@ -356,9 +375,9 @@ func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("version not found: %w", err)
|
||||
}
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
||||
return nil, fmt.Errorf("corrupt snapshot: %w", err)
|
||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find current stage
|
||||
@@ -421,8 +440,8 @@ func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var stages []models.WorkflowStage
|
||||
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
|
||||
stages, parseErr := parseSnapshotStages(ver.Snapshot)
|
||||
if parseErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ func (sc *Scanner) runScan() {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var stages []models.WorkflowStage
|
||||
if json.Unmarshal(v.Snapshot, &stages) != nil {
|
||||
stages, parseErr := parseSnapshotStages(v.Snapshot)
|
||||
if parseErr != nil {
|
||||
return nil
|
||||
}
|
||||
versionCache[key] = stages
|
||||
|
||||
Reference in New Issue
Block a user