From d46fa3d4af4dd8f8f06f447c8c4eaac832dfec53 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:56:22 -0700 Subject: [PATCH 1/5] chore: update loops-go to v0.5.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5de148a..7e8399e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/loops-so/loops-go v0.4.0 + github.com/loops-so/loops-go v0.5.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.6 diff --git a/go.sum b/go.sum index 593aac3..705a743 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/loops-so/loops-go v0.4.0 h1:uS9lSsepdqFdDCbvbZ9HGMTStycI0yaLU931tPYs0gI= -github.com/loops-so/loops-go v0.4.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.5.0 h1:Oh77cLVmD+UYA86LzvNqwrFyh8uHoPA20IcSdqZJ2ZM= +github.com/loops-so/loops-go v0.5.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= From d5a0091dddf91669efff278c27b6897afca54007 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:05:16 -0700 Subject: [PATCH 2/5] feat(workflows): add 'after' node insert mode and migrate 'before' to toNodeId --- cmd/workflows.go | 84 ++++++++++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/cmd/workflows.go b/cmd/workflows.go index 1f942d9..635604b 100644 --- a/cmd/workflows.go +++ b/cmd/workflows.go @@ -601,32 +601,61 @@ var workflowsChangeMailingListCmd = &cobra.Command{ }, } +// parseCreateWorkflowNodeFlags reads and validates the `workflows nodes create` +// flags and builds a CreateWorkflowNodeRequest. Placement depends on insert +// mode: "between" needs --from-node-id and --to-node-id; "before" inserts +// before --before-node-id; "after" inserts after --from-node-id (valid only +// when that node has exactly one outgoing connection). "before" is sent as +// toNodeId because the API's beforeNodeId field is deprecated. +func parseCreateWorkflowNodeFlags(cmd *cobra.Command) (loops.CreateWorkflowNodeRequest, error) { + nodeType, _ := cmd.Flags().GetString("node-type") + insertMode, _ := cmd.Flags().GetString("insert-mode") + fromNodeID, _ := cmd.Flags().GetString("from-node-id") + toNodeID, _ := cmd.Flags().GetString("to-node-id") + beforeNodeID, _ := cmd.Flags().GetString("before-node-id") + + if !slices.Contains(createWorkflowNodeTypes, nodeType) { + return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--node-type must be one of: %s", strings.Join(createWorkflowNodeTypes, ", ")) + } + + req := loops.CreateWorkflowNodeRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + InsertMode: insertMode, + NodeTypeName: nodeType, + } + + switch insertMode { + case loops.WorkflowInsertModeBetween: + if fromNodeID == "" || toNodeID == "" { + return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode between requires --from-node-id and --to-node-id") + } + req.FromNodeID = fromNodeID + req.ToNodeID = toNodeID + case loops.WorkflowInsertModeBefore: + if beforeNodeID == "" { + return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode before requires --before-node-id") + } + req.ToNodeID = beforeNodeID + case loops.WorkflowInsertModeAfter: + if fromNodeID == "" { + return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode after requires --from-node-id") + } + req.FromNodeID = fromNodeID + default: + return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode must be %q, %q, or %q", loops.WorkflowInsertModeBetween, loops.WorkflowInsertModeBefore, loops.WorkflowInsertModeAfter) + } + + return req, nil +} + var workflowsNodesCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a workflow node", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - nodeType, _ := cmd.Flags().GetString("node-type") - insertMode, _ := cmd.Flags().GetString("insert-mode") - fromNodeID, _ := cmd.Flags().GetString("from-node-id") - toNodeID, _ := cmd.Flags().GetString("to-node-id") - beforeNodeID, _ := cmd.Flags().GetString("before-node-id") - - if !slices.Contains(createWorkflowNodeTypes, nodeType) { - return fmt.Errorf("--node-type must be one of: %s", strings.Join(createWorkflowNodeTypes, ", ")) - } - - switch insertMode { - case loops.WorkflowInsertModeBetween: - if fromNodeID == "" || toNodeID == "" { - return fmt.Errorf("--insert-mode between requires --from-node-id and --to-node-id") - } - case loops.WorkflowInsertModeBefore: - if beforeNodeID == "" { - return fmt.Errorf("--insert-mode before requires --before-node-id") - } - default: - return fmt.Errorf("--insert-mode must be %q or %q", loops.WorkflowInsertModeBetween, loops.WorkflowInsertModeBefore) + req, err := parseCreateWorkflowNodeFlags(cmd) + if err != nil { + return err } cfg, err := loadConfig() @@ -634,14 +663,7 @@ var workflowsNodesCreateCmd = &cobra.Command{ return err } - resp, err := runWorkflowsNodeCreate(cfg, args[0], loops.CreateWorkflowNodeRequest{ - ExpectedRevisionID: readExpectedRevisionID(cmd), - InsertMode: insertMode, - NodeTypeName: nodeType, - FromNodeID: fromNodeID, - ToNodeID: toNodeID, - BeforeNodeID: beforeNodeID, - }) + resp, err := runWorkflowsNodeCreate(cfg, args[0], req) if err != nil { return err } @@ -835,8 +857,8 @@ func init() { workflowsNodesCmd.AddCommand(workflowsNodesGetCmd) workflowsNodesCreateCmd.Flags().String("node-type", "", fmt.Sprintf("Node type: %s", strings.Join(createWorkflowNodeTypes, ", "))) - workflowsNodesCreateCmd.Flags().String("insert-mode", "", "Insert mode: between or before") - workflowsNodesCreateCmd.Flags().String("from-node-id", "", "Source node ID (insert-mode between)") + workflowsNodesCreateCmd.Flags().String("insert-mode", "", "Insert mode: between, before, or after") + workflowsNodesCreateCmd.Flags().String("from-node-id", "", "Source node ID (insert-mode between or after)") workflowsNodesCreateCmd.Flags().String("to-node-id", "", "Target node ID (insert-mode between)") workflowsNodesCreateCmd.Flags().String("before-node-id", "", "Node ID to insert before (insert-mode before)") workflowsNodesCreateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") From aa54c911759c5dd77810ef81627e67238e845b1b Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:07:26 -0700 Subject: [PATCH 3/5] feat(workflows): add 'nodes reroute' command --- cmd/workflows.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cmd/workflows.go b/cmd/workflows.go index 635604b..6d7980d 100644 --- a/cmd/workflows.go +++ b/cmd/workflows.go @@ -470,6 +470,10 @@ func runWorkflowsNodeAddBranch(cfg *config.Config, workflowID, nodeID string, re return newAPIClient(cfg).AddWorkflowBranch(workflowID, nodeID, req) } +func runWorkflowsNodeReroute(cfg *config.Config, workflowID, nodeID string, req loops.RerouteNodeConnectionRequest) (*loops.RerouteNodeConnectionResponse, error) { + return newAPIClient(cfg).RerouteNodeConnection(workflowID, nodeID, req) +} + func runWorkflowsNodeDelete(cfg *config.Config, workflowID, nodeID string, recursive bool, req loops.DeleteWorkflowNodeRequest) (*loops.DeleteWorkflowNodeResponse, error) { client := newAPIClient(cfg) if recursive { @@ -740,6 +744,35 @@ var workflowsNodesAddBranchCmd = &cobra.Command{ }, } +var workflowsNodesRerouteCmd = &cobra.Command{ + Use: "reroute ", + Short: "Reroute a node's outgoing connection to a new target", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + newTargetNodeID, _ := cmd.Flags().GetString("new-target-node-id") + + cfg, err := loadConfig() + if err != nil { + return err + } + + resp, err := runWorkflowsNodeReroute(cfg, args[0], args[1], loops.RerouteNodeConnectionRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + NewTargetNodeID: newTargetNodeID, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Rerouted node. (revision: %s)\n\n", resp.WorkflowRevisionID) + return printSimplifiedWorkflow(cmd, &resp.Workflow) + }, +} + var workflowsNodesDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a workflow node", @@ -874,6 +907,11 @@ func init() { workflowsNodesAddBranchCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") workflowsNodesCmd.AddCommand(workflowsNodesAddBranchCmd) + workflowsNodesRerouteCmd.Flags().String("new-target-node-id", "", "Node ID that should receive the source node's outgoing connection") + workflowsNodesRerouteCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsNodesRerouteCmd.MarkFlagRequired("new-target-node-id") + workflowsNodesCmd.AddCommand(workflowsNodesRerouteCmd) + workflowsNodesDeleteCmd.Flags().Bool("recursive", false, "Also delete downstream nodes") workflowsNodesDeleteCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") workflowsNodesDeleteCmd.Flags().Bool("dry-run", false, "Report queued-contact impact without applying the deletion") From 1abbe4bdf7ec7db68ca84873bd996346153e5289 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:12:27 -0700 Subject: [PATCH 4/5] feat(workflows): show returned workflow in node update/delete and change-mailing-list output --- cmd/workflows.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cmd/workflows.go b/cmd/workflows.go index 6d7980d..668ba80 100644 --- a/cmd/workflows.go +++ b/cmd/workflows.go @@ -488,7 +488,14 @@ func printChangeMailingListResponse(cmd *cobra.Command, r *loops.ChangeWorkflowM t.Row("mailingListId", deref(r.MailingListID)) t.Row("workflowRevisionId", deref(r.WorkflowRevisionID)) t.Row("queuedContactCount", formatFloat(r.QueuedContactCount)) - return t.Render() + if err := t.Render(); err != nil { + return err + } + if r.Workflow == nil { + return nil + } + fmt.Fprintln(cmd.OutOrStdout()) + return printSimplifiedWorkflow(cmd, r.Workflow) } func printDeleteNodeResponse(cmd *cobra.Command, r *loops.DeleteWorkflowNodeResponse) error { @@ -497,7 +504,14 @@ func printDeleteNodeResponse(cmd *cobra.Command, r *loops.DeleteWorkflowNodeResp t.Row("nodeIds", strings.Join(r.NodeIDs, ", ")) t.Row("workflowRevisionId", deref(r.WorkflowRevisionID)) t.Row("queuedContactCount", formatFloat(r.QueuedContactCount)) - return t.Render() + if err := t.Render(); err != nil { + return err + } + if r.Workflow == nil { + return nil + } + fmt.Fprintln(cmd.OutOrStdout()) + return printSimplifiedWorkflow(cmd, r.Workflow) } var workflowsCreateCmd = &cobra.Command{ @@ -714,7 +728,11 @@ var workflowsNodesUpdateCmd = &cobra.Command{ t.Row("typeName", node.TypeName) t.Row("nodeId", mutationNodeID(&node.WorkflowMutationNode)) t.Row("workflowRevisionId", node.WorkflowRevisionID) - return t.Render() + if err := t.Render(); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout()) + return printSimplifiedWorkflow(cmd, &node.Workflow) }, } From 4b043b6890ad16873a3350839dbeb1d569831f6a Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:14:23 -0700 Subject: [PATCH 5/5] test(workflows): cover node create insert modes and reroute --- cmd/workflows_nodes_write_test.go | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/cmd/workflows_nodes_write_test.go b/cmd/workflows_nodes_write_test.go index d5c2fc6..26b2d50 100644 --- a/cmd/workflows_nodes_write_test.go +++ b/cmd/workflows_nodes_write_test.go @@ -8,8 +8,119 @@ import ( "testing" "github.com/loops-so/loops-go" + "github.com/spf13/cobra" ) +// newCreateNodeFlagsCmd builds a command with the `workflows nodes create` +// flags registered and set from the given map, for testing +// parseCreateWorkflowNodeFlags in isolation. +func newCreateNodeFlagsCmd(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + c := &cobra.Command{} + for _, name := range []string{"node-type", "insert-mode", "from-node-id", "to-node-id", "before-node-id", "expected-revision-id"} { + c.Flags().String(name, "", "") + } + for k, v := range flags { + if err := c.Flags().Set(k, v); err != nil { + t.Fatalf("set --%s: %v", k, err) + } + } + return c +} + +func TestParseCreateWorkflowNodeFlags(t *testing.T) { + t.Run("between sets from/to node ids", func(t *testing.T) { + req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{ + "node-type": loops.CreateWorkflowNodeTypeTimerAction, + "insert-mode": loops.WorkflowInsertModeBetween, + "from-node-id": "n1", + "to-node-id": "n2", + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.FromNodeID != "n1" || req.ToNodeID != "n2" { + t.Errorf("from/to = %q/%q, want n1/n2", req.FromNodeID, req.ToNodeID) + } + if req.BeforeNodeID != "" { + t.Errorf("BeforeNodeID = %q, want empty", req.BeforeNodeID) + } + }) + + t.Run("before maps before-node-id to ToNodeID (not BeforeNodeID)", func(t *testing.T) { + req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{ + "node-type": loops.CreateWorkflowNodeTypeTimerAction, + "insert-mode": loops.WorkflowInsertModeBefore, + "before-node-id": "n3", + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.ToNodeID != "n3" { + t.Errorf("ToNodeID = %q, want n3", req.ToNodeID) + } + if req.BeforeNodeID != "" { + t.Errorf("BeforeNodeID = %q, want empty (deprecated field must not be sent)", req.BeforeNodeID) + } + if req.FromNodeID != "" { + t.Errorf("FromNodeID = %q, want empty", req.FromNodeID) + } + }) + + t.Run("after sets FromNodeID", func(t *testing.T) { + req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{ + "node-type": loops.CreateWorkflowNodeTypeTimerAction, + "insert-mode": loops.WorkflowInsertModeAfter, + "from-node-id": "n1", + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.InsertMode != loops.WorkflowInsertModeAfter { + t.Errorf("InsertMode = %q, want after", req.InsertMode) + } + if req.FromNodeID != "n1" { + t.Errorf("FromNodeID = %q, want n1", req.FromNodeID) + } + if req.ToNodeID != "" || req.BeforeNodeID != "" { + t.Errorf("to/before = %q/%q, want empty", req.ToNodeID, req.BeforeNodeID) + } + }) + + t.Run("expected-revision-id is passed through when set", func(t *testing.T) { + req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{ + "node-type": loops.CreateWorkflowNodeTypeTimerAction, + "insert-mode": loops.WorkflowInsertModeAfter, + "from-node-id": "n1", + "expected-revision-id": "rev_1", + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.ExpectedRevisionID == nil || *req.ExpectedRevisionID != "rev_1" { + t.Errorf("ExpectedRevisionID = %v, want rev_1", req.ExpectedRevisionID) + } + }) + + errCases := []struct { + name string + flags map[string]string + }{ + {"unknown node-type", map[string]string{"node-type": "Nonsense", "insert-mode": loops.WorkflowInsertModeAfter, "from-node-id": "n1"}}, + {"unknown insert-mode", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": "sideways", "from-node-id": "n1"}}, + {"between missing to", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeBetween, "from-node-id": "n1"}}, + {"before missing before-node-id", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeBefore}}, + {"after missing from-node-id", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeAfter}}, + } + for _, tc := range errCases { + t.Run(tc.name+" is an error", func(t *testing.T) { + if _, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, tc.flags)); err == nil { + t.Error("expected error, got nil") + } + }) + } +} + func TestRunWorkflowsNodeCreate(t *testing.T) { body := `{ "node": { @@ -257,6 +368,61 @@ func TestRunWorkflowsNodeAddBranch(t *testing.T) { }) } +func TestRunWorkflowsNodeReroute(t *testing.T) { + body := `{ + "typeName": "TimerAction", + "id": "node_r", + "nextNodeIds": ["n2"], + "amount": 0, + "unit": "m", + "workflowRevisionId": "rev_8", + "workflow": { + "id": "wf_1", + "name": "WF", + "status": "Draft", + "workflowRevisionId": "rev_8", + "mailingListId": null, + "rootNodeId": null, + "nodes": {} + } + }` + + t.Run("posts to reroute path with new target and revision", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + rev := "rev_7" + resp, err := runWorkflowsNodeReroute(cfg(t), "wf_1", "node_r", loops.RerouteNodeConnectionRequest{ + ExpectedRevisionID: &rev, + NewTargetNodeID: "node_new", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.WorkflowRevisionID != "rev_8" { + t.Errorf("WorkflowRevisionID = %q, want rev_8", resp.WorkflowRevisionID) + } + if resp.Workflow.ID != "wf_1" { + t.Errorf("Workflow.ID = %q, want wf_1", resp.Workflow.ID) + } + if cap.Method != http.MethodPost { + t.Errorf("Method = %q, want POST", cap.Method) + } + if cap.Path != "/workflows/wf_1/nodes/node_r/reroute" { + t.Errorf("Path = %q, want /workflows/wf_1/nodes/node_r/reroute", cap.Path) + } + + var sent map[string]any + if err := json.Unmarshal(cap.Body, &sent); err != nil { + t.Fatalf("decode body: %v\nraw: %s", err, cap.Body) + } + if sent["newTargetNodeId"] != "node_new" { + t.Errorf("newTargetNodeId = %v, want node_new", sent["newTargetNodeId"]) + } + if sent["expectedRevisionId"] != "rev_7" { + t.Errorf("expectedRevisionId = %v, want rev_7", sent["expectedRevisionId"]) + } + }) +} + func TestRunWorkflowsNodeDelete(t *testing.T) { body := `{"status":"deleted","nodeIds":["node_x"],"workflowRevisionId":"rev_7","queuedContactCount":0}`