From ee06f9c134f7c38f7f34b89691c885fc32d77d22 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Thu, 29 Oct 2026 16:59:12 +0000 Subject: [PATCH 1/5] feat(paywalls): edit a flow screen step with --step-id Hidden --step-id on `rc paywalls edit ` runs the Astra v3 editor focused on one screen step of a Flow and saves only the steps the run edited back to the flow draft, guarded by the revision read before the run. A failed run saves nothing; a revision conflict is reported and not retried. Edits without --step-id keep the v1 paywall path. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cli/paywalls_ai.go | 12 + internal/cli/paywalls_ai_flow.go | 306 ++++++++++++++++++++++++++ internal/cli/paywalls_ai_flow_test.go | 187 ++++++++++++++++ internal/paywallai/paywallai.go | 38 +++- internal/paywallai/paywallai_test.go | 42 ++++ 5 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 internal/cli/paywalls_ai_flow.go create mode 100644 internal/cli/paywalls_ai_flow_test.go diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 1919e2d9..d8e2e8dc 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -249,6 +249,7 @@ screenshots via --image, audience via --context.`, } func newPaywallsEditCmd() *cobra.Command { + var stepID string opts := paywallAIOptions{ prompt: os.Getenv("RC_PAYWALL_PROMPT"), baseURL: devEnvOrDefault("RC_PAYWALL_AI_BASE_URL", paywallai.DefaultBaseURL), @@ -310,6 +311,15 @@ Using it well: Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if stepID != "" { + if opts.sessionPath != "" { + return fmt.Errorf("--step-id keeps its own per-step session; drop --session") + } + if argAt(args, 0) == "" { + return WithHint(fmt.Errorf("--step-id needs the flow id"), "rc paywalls edit --step-id ; find ids with: rc flows list --type paywall") + } + return runFlowEdit(cmd.Context(), rt, opts, args[0], stepID) + } var session *paywallAISession var err error switch { @@ -350,6 +360,8 @@ Using it well: }, } addPaywallAIFlags(cmd, &opts) + cmd.Flags().StringVar(&stepID, "step-id", "", "edit one screen step of a flow (the argument is then the flow id)") + _ = cmd.Flags().MarkHidden("step-id") return cmd } diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go new file mode 100644 index 00000000..47495271 --- /dev/null +++ b/internal/cli/paywalls_ai_flow.go @@ -0,0 +1,306 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/revenuecat/cli/internal/api" + "github.com/revenuecat/cli/internal/paywallai" +) + +// paywallAIFlowSession is the conversation memory for one Flow step. Astra +// reads the step's content from the Flow itself, so no paywall is stored. +type paywallAIFlowSession struct { + Version int `json:"version"` + ProjectID string `json:"project_id"` + FlowID string `json:"flow_id"` + StepID string `json:"step_id"` + SessionID string `json:"session_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + UIConfig json.RawMessage `json:"ui_config"` + ProductVariables map[string]string `json:"product_variables"` + SessionItems json.RawMessage `json:"__unstable_session_items"` + AppContext json.RawMessage `json:"app_context,omitempty"` +} + +func defaultFlowSessionPath(projectID, flowID, stepID string) (string, error) { + base, err := defaultPaywallSessionPath(projectID, flowID) + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(base), "session."+stepID+paywallSessionSuffix), nil +} + +func loadFlowSession(path, projectID, flowID, stepID string) *paywallAIFlowSession { + fresh := &paywallAIFlowSession{ + Version: 2, + ProjectID: projectID, + FlowID: flowID, + StepID: stepID, + UIConfig: json.RawMessage(minimalUIConfig), + ProductVariables: map[string]string{}, + SessionItems: json.RawMessage(`{}`), + } + payload, err := os.ReadFile(path) + if err != nil { + return fresh + } + var stored paywallAIFlowSession + if json.Unmarshal(payload, &stored) != nil || stored.Version != 2 || + stored.ProjectID != projectID || stored.FlowID != flowID || stored.StepID != stepID { + return fresh + } + return &stored +} + +func saveFlowSession(path string, session *paywallAIFlowSession) error { + payload, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(payload, '\n'), 0o600) +} + +func applyFlowEvent(session *paywallAIFlowSession, event *paywallai.Event) { + if event.SessionID != "" { + session.SessionID = event.SessionID + } + if event.TraceID != "" { + session.TraceID = event.TraceID + } + if len(event.SessionItems) > 0 { + session.SessionItems = event.SessionItems + } + if len(event.AppContext) > 0 { + session.AppContext = event.AppContext + } +} + +// runFlowEdit drives one Astra run against a screen step of a Flow and saves +// the edited steps onto the Flow draft itself. +func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID, stepID string) error { + client, err := rt.API() + if err != nil { + return err + } + projectID, err := requireProject(rt) + if err != nil { + return err + } + draft, err := client.Flows.GetDraft(ctx, projectID, flowID) + if err != nil { + if api.IsNotFound(err) { + return WithHint(err, "Pass the flow id: rc flows list --type paywall") + } + return err + } + if err := requireScreenStep(draft, stepID); err != nil { + return err + } + if err := requirePaywallAIPrompt(rt, &opts.prompt, "What should change?"); err != nil { + return err + } + if opts.sessionPath, err = defaultFlowSessionPath(projectID, flowID, stepID); err != nil { + return err + } + session := loadFlowSession(opts.sessionPath, projectID, flowID, stepID) + + astra, err := paywallAIClient(rt, opts.baseURL) + if err != nil { + return err + } + attachments, textRefs, err := loadPaywallAIAttachments(opts.images, opts.attachments) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, opts.timeout) + defer cancel() + + rt.Out.Info("Designing with the Paywalls AI Editor — this can take a few minutes…") + stream, err := astra.StreamV3(ctx, paywallai.EditorRequestV3{ + ProjectID: projectID, + SessionID: session.SessionID, + UIConfig: session.UIConfig, + ProductVariables: session.ProductVariables, + Message: withPaywallContext(opts.prompt, opts.context) + textRefs, + InputAttachments: attachments, + SessionItems: session.SessionItems, + AppContext: session.AppContext, + WorkflowContext: paywallai.WorkflowContext{ + Version: 3, + WorkflowID: flowID, + FocusedStepID: stepID, + WorkflowRevision: draft.Revision, + }, + IncludeResultScreenshots: true, + }) + if err != nil { + return err + } + defer stream.Close() + + reportedActivity := 0 + for { + event, err := stream.Next() + if err != nil { + return WithHint(fmt.Errorf("the Paywall AI editor stream ended before the run finished: %w", err), "Nothing was saved. Re-run the command to try again.") + } + switch event.Type { + case paywallai.EventRunStarted, paywallai.EventTurnSnapshot: + applyFlowEvent(session, event) + _ = saveFlowSession(opts.sessionPath, session) + reportedActivity = reportPaywallAIActivity(rt, event.Activity, reportedActivity) + case paywallai.EventRunFailed: + // Nothing is saved on a failed run, even if it carries partial edits. + if event.SessionID != "" { + session.SessionID = event.SessionID + _ = saveFlowSession(opts.sessionPath, session) + } + return WithHint(fmt.Errorf("paywall AI editor run failed (%s): %s", event.Error.Code, event.Error.Message), "Nothing was saved. Re-run the command to try again.") + case paywallai.EventRunCompleted: + reportPaywallAIActivity(rt, event.Activity, reportedActivity) + applyFlowEvent(session, event) + if err := saveFlowSession(opts.sessionPath, session); err != nil { + return err + } + return finishFlowEdit(ctx, rt, opts, client, draft, session, event) + } + } +} + +func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, draft *api.FlowDraft, session *paywallAIFlowSession, event *paywallai.Event) error { + screenshots := savePaywallScreenshots(rt, opts.sessionPath, event.ResultScreenshots) + saved, err := saveEditedSteps(ctx, client, session.ProjectID, draft, event.EditedPaywallsByStepID) + switch { + case err == nil && len(saved) == 0: + rt.Out.Info("The editor made no changes to the flow.") + case err == nil: + rt.Out.Success(fmt.Sprintf("Design saved to flow draft %s (%s)", session.FlowID, strings.Join(saved, ", "))) + if path, ok := screenshots["light"]; ok { + rt.Out.Field("Preview", path) + } + if path, ok := screenshots["dark"]; ok { + rt.Out.Field("Preview (dark)", path) + } + rt.Out.Field("Keep designing", fmt.Sprintf("rc paywalls edit %s --step-id %s", session.FlowID, session.StepID)) + default: + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 409 { + // The edits were made against a stale revision, so they are never replayed automatically. + rt.Out.Warn("Nothing was saved: the flow changed during the run (dashboard or another client).") + rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc paywalls edit %s --step-id %s", session.FlowID, session.StepID)) + } else { + rt.Out.Warn("Could not save the design to RevenueCat: " + err.Error()) + rt.Out.Hint("Nothing was saved. Re-run the command to try again.") + } + } + if rt.Out.IsJSON() { + return rt.Out.Render(map[string]any{ + "flow_id": session.FlowID, + "step_id": session.StepID, + "saved_steps": saved, + "saved_to_draft": len(saved) > 0, + "session_id": session.SessionID, + "trace_id": session.TraceID, + "session_file": opts.sessionPath, + "screenshot_paths": screenshots, + "activity": event.Activity, + }) + } + return nil +} + +// saveEditedSteps writes every step the run edited back in one draft update, +// guarded by the revision the run started from. +func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, draft *api.FlowDraft, edited map[string]paywallai.PaywallData) ([]string, error) { + if len(edited) == 0 { + return nil, nil + } + updated := map[string]json.RawMessage{} + for stepID, paywall := range edited { + raw, ok := draft.Steps[stepID] + if !ok { + return nil, fmt.Errorf("the editor returned step %q, which is not in flow %s", stepID, draft.ID) + } + merged, err := mergeStepPaywall(raw, paywall) + if err != nil { + return nil, err + } + updated[stepID] = merged + } + if _, err := client.Flows.UpdateDraft(ctx, projectID, draft.ID, api.UpdateFlowDraft{ + Revision: draft.Revision, + UpdatedSteps: updated, + }); err != nil { + return nil, err + } + ids := make([]string, 0, len(updated)) + for id := range updated { + ids = append(ids, id) + } + sort.Strings(ids) + return ids, nil +} + +// mergeStepPaywall replaces the editable paywall fields of a draft step and +// keeps everything else on the step untouched, offering included. +func mergeStepPaywall(step json.RawMessage, paywall paywallai.PaywallData) (json.RawMessage, error) { + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(step, &fields); err != nil { + return nil, fmt.Errorf("decoding flow step: %w", err) + } + pw := map[string]json.RawMessage{} + if len(fields["paywall"]) > 0 { + if err := json.Unmarshal(fields["paywall"], &pw); err != nil { + return nil, fmt.Errorf("decoding flow step paywall: %w", err) + } + } + set := func(key string, value any) error { + raw, err := json.Marshal(value) + pw[key] = raw + return err + } + if err := set("default_locale", paywall.DefaultLocale); err != nil { + return nil, err + } + pw["components_config"] = paywall.ComponentsConfig + pw["components_localizations"] = paywall.ComponentsLocalizations + if raw := presentJSON(paywall.StateDeclarations); raw != nil { + pw["state_declarations"] = raw + } + if paywall.AutomaticallyScaleFontSize != nil { + if err := set("automatically_scale_font_size", *paywall.AutomaticallyScaleFontSize); err != nil { + return nil, err + } + } + paywallRaw, err := json.Marshal(pw) + if err != nil { + return nil, err + } + fields["paywall"] = paywallRaw + return json.Marshal(fields) +} + +func requireScreenStep(draft *api.FlowDraft, stepID string) error { + raw, ok := draft.Steps[stepID] + if !ok { + return WithHint(fmt.Errorf("flow %s has no step %q", draft.ID, stepID), "List the flow's steps with: rc flows show "+draft.ID) + } + var step struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &step); err != nil { + return fmt.Errorf("decoding flow step %q: %w", stepID, err) + } + if step.Type != "screen" { + return fmt.Errorf("step %q is a %q step; only screens can be edited", stepID, step.Type) + } + return nil +} diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go new file mode 100644 index 00000000..b4a99616 --- /dev/null +++ b/internal/cli/paywalls_ai_flow_test.go @@ -0,0 +1,187 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const flowDraftJSON = `{"id":"wf_1","object":"flow_draft","revision":4,"type":"paywall","steps":{` + + `"screen":{"type":"screen","name":"Main","paywall":{"offering_id":"ofrng1","components_config":{"old":true},"components_localizations":{},"haptics_enabled":false}},` + + `"other":{"type":"screen","paywall":{"components_config":{"keep":true}}},` + + `"auth":{"type":"auth"}}}` + +const ( + streamStarted = "data: {\"type\":\"run.started\",\"session_id\":\"sess1\"}\n\n" + streamEdited = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"new\":true},\"components_localizations\":{\"en_US\":{}}}}}\n\n" + streamNoEdits = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{}}\n\n" + streamFailed = "data: {\"type\":\"run.failed\",\"session_id\":\"sess1\",\"error\":{\"code\":\"internal\",\"message\":\"boom\"},\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"partial\":true},\"components_localizations\":{}}}}\n\n" +) + +func TestPaywallsEditStepID(t *testing.T) { + tests := []struct { + name string + args []string + stream string + saveStatus int + wantErr string + wantStream bool + wantPatches int + wantOut string + }{ + { + name: "saves the edited step against the draft revision", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEdited, + saveStatus: http.StatusOK, + wantStream: true, + wantPatches: 1, + wantOut: "saved_to_draft", + }, + { + name: "no edits means no save", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamNoEdits, + wantStream: true, + wantPatches: 0, + wantOut: `"saved_to_draft": false`, + }, + { + name: "failed run saves nothing", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamFailed, + wantStream: true, + wantErr: "boom", + wantPatches: 0, + }, + { + name: "revision conflict is reported and not retried", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEdited, + saveStatus: http.StatusConflict, + wantStream: true, + wantPatches: 1, + wantOut: `"saved_to_draft": false`, + }, + { + name: "unknown step", + args: []string{"wf_1", "--step-id", "nope"}, + wantErr: `no step "nope"`, + }, + { + name: "non-screen step", + args: []string{"wf_1", "--step-id", "auth"}, + wantErr: "only screens can be edited", + }, + { + name: "session file is rejected with a step", + args: []string{"wf_1", "--step-id", "screen", "--session", "x.paywall.json"}, + wantErr: "drop --session", + }, + { + name: "flow id is required", + args: []string{"--step-id", "screen"}, + wantErr: "needs the flow id", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var streamed bool + var patches []map[string]json.RawMessage + var streamBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/proj/flows/wf_1/draft": + _, _ = io.WriteString(w, flowDraftJSON) + case r.Method == http.MethodPost && r.URL.Path == "/editor/v3/stream": + streamed = true + _ = json.NewDecoder(r.Body).Decode(&streamBody) + _, _ = io.WriteString(w, tt.stream) + case r.Method == http.MethodPatch && r.URL.Path == "/projects/proj/flows/wf_1/draft": + var body map[string]json.RawMessage + _ = json.NewDecoder(r.Body).Decode(&body) + patches = append(patches, body) + w.WriteHeader(tt.saveStatus) + _, _ = io.WriteString(w, `{"id":"wf_1","revision":5}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + args := append([]string{"paywalls", "edit"}, tt.args...) + args = append(args, "--prompt", "make it blue", "--base-url", server.URL, "--json", "--no-input") + out, _, err := runProjectSetupCommand(t, server.URL, args...) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + } else if err != nil { + t.Fatal(err) + } + if streamed != tt.wantStream { + t.Fatalf("streamed = %v, want %v", streamed, tt.wantStream) + } + if len(patches) != tt.wantPatches { + t.Fatalf("patches = %d, want %d", len(patches), tt.wantPatches) + } + if tt.wantOut != "" && !strings.Contains(out, tt.wantOut) { + t.Fatalf("output missing %q: %s", tt.wantOut, out) + } + if tt.wantStream { + if ctx, _ := streamBody["workflow_context"].(map[string]any); ctx["workflow_id"] != "wf_1" || ctx["focused_step_id"] != "screen" || ctx["workflow_revision"] != float64(4) { + t.Fatalf("workflow_context = %v", streamBody["workflow_context"]) + } + if _, ok := streamBody["paywall"]; ok { + t.Fatal("v3 request carried a paywall") + } + } + if len(patches) == 1 { + patch := patches[0] + if string(patch["revision"]) != "4" { + t.Fatalf("revision = %s, want the draft revision read before the run", patch["revision"]) + } + var updated map[string]map[string]json.RawMessage + if err := json.Unmarshal(patch["updated_steps"], &updated); err != nil { + t.Fatal(err) + } + if len(updated) != 1 { + t.Fatalf("updated steps = %v, want only the edited step", updated) + } + step := updated["screen"] + var paywall map[string]json.RawMessage + if err := json.Unmarshal(step["paywall"], &paywall); err != nil { + t.Fatal(err) + } + if string(step["name"]) != `"Main"` || string(paywall["offering_id"]) != `"ofrng1"` || string(paywall["haptics_enabled"]) != "false" { + t.Fatalf("untouched fields lost: %s", step) + } + if string(paywall["components_config"]) != `{"new":true}` { + t.Fatalf("components_config = %s", paywall["components_config"]) + } + } + }) + } +} + +func TestPaywallsEditWithoutStepIDUsesV1(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + http.Error(w, "stop", http.StatusInternalServerError) + })) + defer server.Close() + _, _, _ = runProjectSetupCommand(t, server.URL, "paywalls", "edit", "pw_1", "--prompt", "x", "--base-url", server.URL, "--json", "--no-input") + for _, p := range paths { + if strings.Contains(p, "/flows/") || strings.Contains(p, "/editor/v3/") { + t.Fatalf("v1 edit touched flow path %s", p) + } + } + if len(paths) == 0 { + t.Fatal("no requests made") + } +} diff --git a/internal/paywallai/paywallai.go b/internal/paywallai/paywallai.go index 5a515b0b..d80e9427 100644 --- a/internal/paywallai/paywallai.go +++ b/internal/paywallai/paywallai.go @@ -69,6 +69,30 @@ type EditorRequest struct { Source string `json:"source"` } +// WorkflowContext scopes a v3 run to one step of a Flow. +type WorkflowContext struct { + Version int `json:"version"` + WorkflowID string `json:"workflow_id"` + FocusedStepID string `json:"focused_step_id"` + WorkflowRevision int `json:"workflow_revision"` +} + +// EditorRequestV3 is the POST /editor/v3/stream body. Astra reads the Flow +// itself, so no paywall is sent. +type EditorRequestV3 struct { + ProjectID string `json:"project_id"` + SessionID string `json:"session_id,omitempty"` + UIConfig json.RawMessage `json:"ui_config"` + ProductVariables map[string]string `json:"product_variables"` + Message string `json:"message"` + InputAttachments []InputAttachment `json:"input_attachments,omitempty"` + SessionItems json.RawMessage `json:"__unstable_session_items"` + AppContext json.RawMessage `json:"app_context,omitempty"` + WorkflowContext WorkflowContext `json:"workflow_context"` + IncludeResultScreenshots bool `json:"include_result_screenshots,omitempty"` + Source string `json:"source"` +} + type ToolActivity struct { ID string `json:"id"` Type string `json:"type"` // "tool" | "assistant_message" @@ -106,6 +130,8 @@ type Event struct { SessionItems json.RawMessage `json:"__unstable_session_items,omitempty"` AppContext json.RawMessage `json:"app_context,omitempty"` ResultScreenshots []ResultScreenshot `json:"result_screenshots,omitempty"` + // v3 only: every step the run edited, keyed by step id. + EditedPaywallsByStepID map[string]PaywallData `json:"edited_paywalls_by_step_id,omitempty"` } func (e *Event) Terminal() bool { @@ -214,7 +240,17 @@ func errorMessage(body []byte) string { // Stream starts an editor run and returns the live event stream. func (c *Client) Stream(ctx context.Context, request EditorRequest) (*Stream, error) { request.Source = editorSource - req, err := c.newRequest(ctx, "/editor/v1/stream", request) + return c.openStream(ctx, "/editor/v1/stream", request) +} + +// StreamV3 starts a Flow-step editor run and returns the live event stream. +func (c *Client) StreamV3(ctx context.Context, request EditorRequestV3) (*Stream, error) { + request.Source = editorSource + return c.openStream(ctx, "/editor/v3/stream", request) +} + +func (c *Client) openStream(ctx context.Context, path string, body any) (*Stream, error) { + req, err := c.newRequest(ctx, path, body) if err != nil { return nil, err } diff --git a/internal/paywallai/paywallai_test.go b/internal/paywallai/paywallai_test.go index c6d6d3c0..100b839d 100644 --- a/internal/paywallai/paywallai_test.go +++ b/internal/paywallai/paywallai_test.go @@ -148,3 +148,45 @@ func TestFeedbackAndRewind(t *testing.T) { t.Fatalf("paths = %v", paths) } } + +func TestStreamV3PostsWorkflowContext(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/editor/v3/stream" { + t.Errorf("path = %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + io.WriteString(w, "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"a\":1},\"components_localizations\":{}}}}\n\n") + })) + defer server.Close() + + client := NewClient(Options{BaseURL: server.URL, Token: "tok"}) + stream, err := client.StreamV3(context.Background(), EditorRequestV3{ + ProjectID: "proj1", + Message: "make it blue", + WorkflowContext: WorkflowContext{Version: 3, WorkflowID: "wf_1", FocusedStepID: "screen", WorkflowRevision: 4}, + }) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + + if _, ok := gotBody["paywall"]; ok { + t.Error("v3 request must not carry a paywall") + } + if gotBody["source"] != "cli" { + t.Errorf("source = %v", gotBody["source"]) + } + if got, _ := gotBody["workflow_context"].(map[string]any); got["workflow_id"] != "wf_1" || got["focused_step_id"] != "screen" || got["workflow_revision"] != float64(4) || got["version"] != float64(3) { + t.Errorf("workflow_context = %v", gotBody["workflow_context"]) + } + event, err := stream.Next() + if err != nil || event.Type != EventRunCompleted { + t.Fatalf("event = %+v, err %v", event, err) + } + if got := event.EditedPaywallsByStepID["screen"]; string(got.ComponentsConfig) != `{"a":1}` { + t.Errorf("edited paywalls = %+v", event.EditedPaywallsByStepID) + } +} From c32739a5b49aa4f206f394cd73a9e97b67d56267 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Thu, 29 Oct 2026 18:05:44 +0000 Subject: [PATCH 2/5] fix(paywalls): send only the edited paywall per step when saving a flow edit The flow draft PATCH now carries {"paywall": {...}} per edited step instead of echoing the whole draft step back, so no read-only keys reach the strict PATCH schema. Tests assert the exact body. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cli/paywalls_ai_flow.go | 47 ++++++---------------- internal/cli/paywalls_ai_flow_test.go | 58 +++++++++++++++------------ 2 files changed, 44 insertions(+), 61 deletions(-) diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go index 47495271..9ddcfac2 100644 --- a/internal/cli/paywalls_ai_flow.go +++ b/internal/cli/paywalls_ai_flow.go @@ -225,15 +225,14 @@ func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, } updated := map[string]json.RawMessage{} for stepID, paywall := range edited { - raw, ok := draft.Steps[stepID] - if !ok { + if _, ok := draft.Steps[stepID]; !ok { return nil, fmt.Errorf("the editor returned step %q, which is not in flow %s", stepID, draft.ID) } - merged, err := mergeStepPaywall(raw, paywall) + patch, err := editedStepPatch(paywall) if err != nil { return nil, err } - updated[stepID] = merged + updated[stepID] = patch } if _, err := client.Flows.UpdateDraft(ctx, projectID, draft.ID, api.UpdateFlowDraft{ Revision: draft.Revision, @@ -249,43 +248,21 @@ func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, return ids, nil } -// mergeStepPaywall replaces the editable paywall fields of a draft step and -// keeps everything else on the step untouched, offering included. -func mergeStepPaywall(step json.RawMessage, paywall paywallai.PaywallData) (json.RawMessage, error) { - fields := map[string]json.RawMessage{} - if err := json.Unmarshal(step, &fields); err != nil { - return nil, fmt.Errorf("decoding flow step: %w", err) - } - pw := map[string]json.RawMessage{} - if len(fields["paywall"]) > 0 { - if err := json.Unmarshal(fields["paywall"], &pw); err != nil { - return nil, fmt.Errorf("decoding flow step paywall: %w", err) - } - } - set := func(key string, value any) error { - raw, err := json.Marshal(value) - pw[key] = raw - return err - } - if err := set("default_locale", paywall.DefaultLocale); err != nil { - return nil, err +// editedStepPatch is the updated_steps entry for an edited screen: only the +// editable paywall fields, which the server merges onto the stored step. +func editedStepPatch(paywall paywallai.PaywallData) (json.RawMessage, error) { + pw := map[string]any{ + "default_locale": paywall.DefaultLocale, + "components_config": paywall.ComponentsConfig, + "components_localizations": paywall.ComponentsLocalizations, } - pw["components_config"] = paywall.ComponentsConfig - pw["components_localizations"] = paywall.ComponentsLocalizations if raw := presentJSON(paywall.StateDeclarations); raw != nil { pw["state_declarations"] = raw } if paywall.AutomaticallyScaleFontSize != nil { - if err := set("automatically_scale_font_size", *paywall.AutomaticallyScaleFontSize); err != nil { - return nil, err - } - } - paywallRaw, err := json.Marshal(pw) - if err != nil { - return nil, err + pw["automatically_scale_font_size"] = *paywall.AutomaticallyScaleFontSize } - fields["paywall"] = paywallRaw - return json.Marshal(fields) + return json.Marshal(map[string]any{"paywall": pw}) } func requireScreenStep(draft *api.FlowDraft, stepID string) error { diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go index b4a99616..7a065898 100644 --- a/internal/cli/paywalls_ai_flow_test.go +++ b/internal/cli/paywalls_ai_flow_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" ) @@ -15,8 +16,11 @@ const flowDraftJSON = `{"id":"wf_1","object":"flow_draft","revision":4,"type":"p `"auth":{"type":"auth"}}}` const ( - streamStarted = "data: {\"type\":\"run.started\",\"session_id\":\"sess1\"}\n\n" - streamEdited = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"new\":true},\"components_localizations\":{\"en_US\":{}}}}}\n\n" + streamStarted = "data: {\"type\":\"run.started\",\"session_id\":\"sess1\"}\n\n" + streamEdited = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"new\":true},\"components_localizations\":{\"en_US\":{}}}}}\n\n" + streamEditedFull = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{" + + "\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"a\":1},\"components_localizations\":{},\"state_declarations\":{\"x\":1},\"automatically_scale_font_size\":false}," + + "\"other\":{\"default_locale\":\"fr_FR\",\"state_declarations\":null,\"components_config\":{\"b\":2},\"components_localizations\":{}}}}\n\n" streamNoEdits = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{}}\n\n" streamFailed = "data: {\"type\":\"run.failed\",\"session_id\":\"sess1\",\"error\":{\"code\":\"internal\",\"message\":\"boom\"},\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"partial\":true},\"components_localizations\":{}}}}\n\n" ) @@ -31,6 +35,7 @@ func TestPaywallsEditStepID(t *testing.T) { wantStream bool wantPatches int wantOut string + wantPatch string }{ { name: "saves the edited step against the draft revision", @@ -40,6 +45,19 @@ func TestPaywallsEditStepID(t *testing.T) { wantStream: true, wantPatches: 1, wantOut: "saved_to_draft", + wantPatch: `{"revision":4,"updated_steps":{"screen":{"paywall":{"default_locale":"en_US","components_config":{"new":true},"components_localizations":{"en_US":{}}}}}}`, + }, + { + name: "every edited step sends only its paywall", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEditedFull, + saveStatus: http.StatusOK, + wantStream: true, + wantPatches: 1, + wantOut: "saved_to_draft", + wantPatch: `{"revision":4,"updated_steps":{` + + `"screen":{"paywall":{"default_locale":"en_US","components_config":{"a":1},"components_localizations":{},"state_declarations":{"x":1},"automatically_scale_font_size":false}},` + + `"other":{"paywall":{"default_locale":"fr_FR","components_config":{"b":2},"components_localizations":{}}}}}`, }, { name: "no edits means no save", @@ -90,7 +108,8 @@ func TestPaywallsEditStepID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var streamed bool - var patches []map[string]json.RawMessage + var patchBody []byte + var patches int var streamBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -101,9 +120,8 @@ func TestPaywallsEditStepID(t *testing.T) { _ = json.NewDecoder(r.Body).Decode(&streamBody) _, _ = io.WriteString(w, tt.stream) case r.Method == http.MethodPatch && r.URL.Path == "/projects/proj/flows/wf_1/draft": - var body map[string]json.RawMessage - _ = json.NewDecoder(r.Body).Decode(&body) - patches = append(patches, body) + patchBody, _ = io.ReadAll(r.Body) + patches++ w.WriteHeader(tt.saveStatus) _, _ = io.WriteString(w, `{"id":"wf_1","revision":5}`) default: @@ -126,8 +144,8 @@ func TestPaywallsEditStepID(t *testing.T) { if streamed != tt.wantStream { t.Fatalf("streamed = %v, want %v", streamed, tt.wantStream) } - if len(patches) != tt.wantPatches { - t.Fatalf("patches = %d, want %d", len(patches), tt.wantPatches) + if patches != tt.wantPatches { + t.Fatalf("patches = %d, want %d", patches, tt.wantPatches) } if tt.wantOut != "" && !strings.Contains(out, tt.wantOut) { t.Fatalf("output missing %q: %s", tt.wantOut, out) @@ -140,28 +158,16 @@ func TestPaywallsEditStepID(t *testing.T) { t.Fatal("v3 request carried a paywall") } } - if len(patches) == 1 { - patch := patches[0] - if string(patch["revision"]) != "4" { - t.Fatalf("revision = %s, want the draft revision read before the run", patch["revision"]) - } - var updated map[string]map[string]json.RawMessage - if err := json.Unmarshal(patch["updated_steps"], &updated); err != nil { + if tt.wantPatch != "" { + var want, got any + if err := json.Unmarshal([]byte(tt.wantPatch), &want); err != nil { t.Fatal(err) } - if len(updated) != 1 { - t.Fatalf("updated steps = %v, want only the edited step", updated) - } - step := updated["screen"] - var paywall map[string]json.RawMessage - if err := json.Unmarshal(step["paywall"], &paywall); err != nil { + if err := json.Unmarshal(patchBody, &got); err != nil { t.Fatal(err) } - if string(step["name"]) != `"Main"` || string(paywall["offering_id"]) != `"ofrng1"` || string(paywall["haptics_enabled"]) != "false" { - t.Fatalf("untouched fields lost: %s", step) - } - if string(paywall["components_config"]) != `{"new":true}` { - t.Fatalf("components_config = %s", paywall["components_config"]) + if !reflect.DeepEqual(got, want) { + t.Fatalf("PATCH body = %s\nwant %s", patchBody, tt.wantPatch) } } }) From 14bf6b35dd1eb4794241f299520e994b92c4ee5f Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Thu, 29 Oct 2026 18:55:55 +0000 Subject: [PATCH 3/5] refactor(paywalls): trim flow edit session handling Flow session loading starts fresh only when the file is missing and reports parse errors like the v1 loader. The flow session drops its unused version and identity fields and reuses savePaywallAISession. A duplicate v1 test and the request-shape assertions already covered in paywallai are removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cli/paywalls_ai.go | 2 +- internal/cli/paywalls_ai_flow.go | 69 +++++++++++---------------- internal/cli/paywalls_ai_flow_test.go | 23 +-------- 3 files changed, 31 insertions(+), 63 deletions(-) diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index d8e2e8dc..6e001417 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -894,7 +894,7 @@ func loadPaywallAISession(rt *Runtime, path string) (*paywallAISession, error) { return &session, nil } -func savePaywallAISession(path string, session *paywallAISession) error { +func savePaywallAISession(path string, session any) error { payload, err := json.MarshalIndent(session, "", " ") if err != nil { return err diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go index 9ddcfac2..9c2f8ec2 100644 --- a/internal/cli/paywalls_ai_flow.go +++ b/internal/cli/paywalls_ai_flow.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -17,10 +18,6 @@ import ( // paywallAIFlowSession is the conversation memory for one Flow step. Astra // reads the step's content from the Flow itself, so no paywall is stored. type paywallAIFlowSession struct { - Version int `json:"version"` - ProjectID string `json:"project_id"` - FlowID string `json:"flow_id"` - StepID string `json:"step_id"` SessionID string `json:"session_id,omitempty"` TraceID string `json:"trace_id,omitempty"` UIConfig json.RawMessage `json:"ui_config"` @@ -37,34 +34,23 @@ func defaultFlowSessionPath(projectID, flowID, stepID string) (string, error) { return filepath.Join(filepath.Dir(base), "session."+stepID+paywallSessionSuffix), nil } -func loadFlowSession(path, projectID, flowID, stepID string) *paywallAIFlowSession { - fresh := &paywallAIFlowSession{ - Version: 2, - ProjectID: projectID, - FlowID: flowID, - StepID: stepID, - UIConfig: json.RawMessage(minimalUIConfig), - ProductVariables: map[string]string{}, - SessionItems: json.RawMessage(`{}`), - } +func loadFlowSession(path string) (*paywallAIFlowSession, error) { payload, err := os.ReadFile(path) - if err != nil { - return fresh + if errors.Is(err, fs.ErrNotExist) { + return &paywallAIFlowSession{ + UIConfig: json.RawMessage(minimalUIConfig), + ProductVariables: map[string]string{}, + SessionItems: json.RawMessage(`{}`), + }, nil } - var stored paywallAIFlowSession - if json.Unmarshal(payload, &stored) != nil || stored.Version != 2 || - stored.ProjectID != projectID || stored.FlowID != flowID || stored.StepID != stepID { - return fresh - } - return &stored -} - -func saveFlowSession(path string, session *paywallAIFlowSession) error { - payload, err := json.MarshalIndent(session, "", " ") if err != nil { - return err + return nil, fmt.Errorf("reading session file: %w", err) + } + var session paywallAIFlowSession + if err := json.Unmarshal(payload, &session); err != nil { + return nil, fmt.Errorf("parsing session file %s: %w", path, err) } - return os.WriteFile(path, append(payload, '\n'), 0o600) + return &session, nil } func applyFlowEvent(session *paywallAIFlowSession, event *paywallai.Event) { @@ -109,7 +95,10 @@ func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID if opts.sessionPath, err = defaultFlowSessionPath(projectID, flowID, stepID); err != nil { return err } - session := loadFlowSession(opts.sessionPath, projectID, flowID, stepID) + session, err := loadFlowSession(opts.sessionPath) + if err != nil { + return err + } astra, err := paywallAIClient(rt, opts.baseURL) if err != nil { @@ -155,47 +144,47 @@ func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID switch event.Type { case paywallai.EventRunStarted, paywallai.EventTurnSnapshot: applyFlowEvent(session, event) - _ = saveFlowSession(opts.sessionPath, session) + _ = savePaywallAISession(opts.sessionPath, session) reportedActivity = reportPaywallAIActivity(rt, event.Activity, reportedActivity) case paywallai.EventRunFailed: // Nothing is saved on a failed run, even if it carries partial edits. if event.SessionID != "" { session.SessionID = event.SessionID - _ = saveFlowSession(opts.sessionPath, session) + _ = savePaywallAISession(opts.sessionPath, session) } return WithHint(fmt.Errorf("paywall AI editor run failed (%s): %s", event.Error.Code, event.Error.Message), "Nothing was saved. Re-run the command to try again.") case paywallai.EventRunCompleted: reportPaywallAIActivity(rt, event.Activity, reportedActivity) applyFlowEvent(session, event) - if err := saveFlowSession(opts.sessionPath, session); err != nil { + if err := savePaywallAISession(opts.sessionPath, session); err != nil { return err } - return finishFlowEdit(ctx, rt, opts, client, draft, session, event) + return finishFlowEdit(ctx, rt, opts, client, projectID, draft, stepID, session, event) } } } -func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, draft *api.FlowDraft, session *paywallAIFlowSession, event *paywallai.Event) error { +func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, projectID string, draft *api.FlowDraft, stepID string, session *paywallAIFlowSession, event *paywallai.Event) error { screenshots := savePaywallScreenshots(rt, opts.sessionPath, event.ResultScreenshots) - saved, err := saveEditedSteps(ctx, client, session.ProjectID, draft, event.EditedPaywallsByStepID) + saved, err := saveEditedSteps(ctx, client, projectID, draft, event.EditedPaywallsByStepID) switch { case err == nil && len(saved) == 0: rt.Out.Info("The editor made no changes to the flow.") case err == nil: - rt.Out.Success(fmt.Sprintf("Design saved to flow draft %s (%s)", session.FlowID, strings.Join(saved, ", "))) + rt.Out.Success(fmt.Sprintf("Design saved to flow draft %s (%s)", draft.ID, strings.Join(saved, ", "))) if path, ok := screenshots["light"]; ok { rt.Out.Field("Preview", path) } if path, ok := screenshots["dark"]; ok { rt.Out.Field("Preview (dark)", path) } - rt.Out.Field("Keep designing", fmt.Sprintf("rc paywalls edit %s --step-id %s", session.FlowID, session.StepID)) + rt.Out.Field("Keep designing", fmt.Sprintf("rc paywalls edit %s --step-id %s", draft.ID, stepID)) default: var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 409 { // The edits were made against a stale revision, so they are never replayed automatically. rt.Out.Warn("Nothing was saved: the flow changed during the run (dashboard or another client).") - rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc paywalls edit %s --step-id %s", session.FlowID, session.StepID)) + rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc paywalls edit %s --step-id %s", draft.ID, stepID)) } else { rt.Out.Warn("Could not save the design to RevenueCat: " + err.Error()) rt.Out.Hint("Nothing was saved. Re-run the command to try again.") @@ -203,8 +192,8 @@ func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, cli } if rt.Out.IsJSON() { return rt.Out.Render(map[string]any{ - "flow_id": session.FlowID, - "step_id": session.StepID, + "flow_id": draft.ID, + "step_id": stepID, "saved_steps": saved, "saved_to_draft": len(saved) > 0, "session_id": session.SessionID, diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go index 7a065898..516fb270 100644 --- a/internal/cli/paywalls_ai_flow_test.go +++ b/internal/cli/paywalls_ai_flow_test.go @@ -151,12 +151,9 @@ func TestPaywallsEditStepID(t *testing.T) { t.Fatalf("output missing %q: %s", tt.wantOut, out) } if tt.wantStream { - if ctx, _ := streamBody["workflow_context"].(map[string]any); ctx["workflow_id"] != "wf_1" || ctx["focused_step_id"] != "screen" || ctx["workflow_revision"] != float64(4) { + if ctx, _ := streamBody["workflow_context"].(map[string]any); ctx["workflow_revision"] != float64(4) { t.Fatalf("workflow_context = %v", streamBody["workflow_context"]) } - if _, ok := streamBody["paywall"]; ok { - t.Fatal("v3 request carried a paywall") - } } if tt.wantPatch != "" { var want, got any @@ -173,21 +170,3 @@ func TestPaywallsEditStepID(t *testing.T) { }) } } - -func TestPaywallsEditWithoutStepIDUsesV1(t *testing.T) { - var paths []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.URL.Path) - http.Error(w, "stop", http.StatusInternalServerError) - })) - defer server.Close() - _, _, _ = runProjectSetupCommand(t, server.URL, "paywalls", "edit", "pw_1", "--prompt", "x", "--base-url", server.URL, "--json", "--no-input") - for _, p := range paths { - if strings.Contains(p, "/flows/") || strings.Contains(p, "/editor/v3/") { - t.Fatalf("v1 edit touched flow path %s", p) - } - } - if len(paths) == 0 { - t.Fatal("no requests made") - } -} From ef62c5a95cb29d7b6be76dba59955e7388d67448 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 11:36:25 +0000 Subject: [PATCH 4/5] feat(flows): move AI screen edit to rc flows edit Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 1 + internal/cli/flows.go | 2 +- internal/cli/paywalls_ai.go | 12 ------ internal/cli/paywalls_ai_flow.go | 56 ++++++++++++++++++++++++++- internal/cli/paywalls_ai_flow_test.go | 13 +++++-- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index 9e66a0c3..961b347f 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -204,6 +204,7 @@ rc flows update [id] --config # save the exported JSO rc flows create --config # create an unpublished web-funnel or general flow (type paywall is rejected) rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy rc flows validate [id] [--revision ] # return saved-draft validation issues +rc flows edit [id] --step-id --prompt # AI-edit one screen step of a flow's draft; per-step session in the CLI data dir # Experiments — development v2 endpoints, normal discoverable CLI surface rc experiments list [--status ] rc experiments show # human detail view shows variants, targeting, placements, and chronological timeline diff --git a/internal/cli/flows.go b/internal/cli/flows.go index 68e05061..90523071 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -24,7 +24,7 @@ RevenueCat dashboard. Checkpoint rules decide which Flow a customer sees.`, } cmd.AddCommand(newFlowsListCmd()) cmd.AddCommand(newFlowsShowCmd(), newFlowsExportCmd(), newFlowsUpdateCmd(), - newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd()) + newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd(), newFlowsEditCmd()) return cmd } diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 6e001417..c90913a4 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -249,7 +249,6 @@ screenshots via --image, audience via --context.`, } func newPaywallsEditCmd() *cobra.Command { - var stepID string opts := paywallAIOptions{ prompt: os.Getenv("RC_PAYWALL_PROMPT"), baseURL: devEnvOrDefault("RC_PAYWALL_AI_BASE_URL", paywallai.DefaultBaseURL), @@ -311,15 +310,6 @@ Using it well: Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) - if stepID != "" { - if opts.sessionPath != "" { - return fmt.Errorf("--step-id keeps its own per-step session; drop --session") - } - if argAt(args, 0) == "" { - return WithHint(fmt.Errorf("--step-id needs the flow id"), "rc paywalls edit --step-id ; find ids with: rc flows list --type paywall") - } - return runFlowEdit(cmd.Context(), rt, opts, args[0], stepID) - } var session *paywallAISession var err error switch { @@ -360,8 +350,6 @@ Using it well: }, } addPaywallAIFlags(cmd, &opts) - cmd.Flags().StringVar(&stepID, "step-id", "", "edit one screen step of a flow (the argument is then the flow id)") - _ = cmd.Flags().MarkHidden("step-id") return cmd } diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go index 9c2f8ec2..ef05769b 100644 --- a/internal/cli/paywalls_ai_flow.go +++ b/internal/cli/paywalls_ai_flow.go @@ -10,6 +10,9 @@ import ( "path/filepath" "sort" "strings" + "time" + + "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" "github.com/revenuecat/cli/internal/paywallai" @@ -68,6 +71,55 @@ func applyFlowEvent(session *paywallAIFlowSession, event *paywallai.Event) { } } +func newFlowsEditCmd() *cobra.Command { + var stepID string + opts := paywallAIOptions{ + prompt: os.Getenv("RC_PAYWALL_PROMPT"), + baseURL: devEnvOrDefault("RC_PAYWALL_AI_BASE_URL", paywallai.DefaultBaseURL), + timeout: 10 * time.Minute, + } + cmd := &cobra.Command{ + Use: "edit [flow-id] --step-id ", + Short: "Edit a Flow screen with the Paywalls AI Editor", + Long: `Applies a natural-language edit to one screen step of a Flow and saves the +result onto the Flow draft (revision-guarded). The editor sees the whole Flow +but is focused on --step-id; it may also adjust other screens. Find step ids +with rc flows show. + +Each step keeps its own editor session in the CLI data dir, so repeating the +command with the same --step-id continues the conversation. See rc paywalls +edit --help for prompting guidance.`, + Example: ` rc flows edit wf_abc --step-id welcome --prompt "Bolder CTA, keep the layout" + rc flows edit wf_abc --step-id welcome --prompt "match this" --attachment DESIGN.md --image home.png`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + if opts.sessionPath != "" { + return fmt.Errorf("--step-id keeps its own per-step session; drop --session") + } + client, err := rt.API() + if err != nil { + return err + } + projectID, err := requireProject(rt) + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + return runFlowEdit(cmd.Context(), rt, opts, flowID, stepID) + }, + } + addPaywallAIFlags(cmd, &opts) + cmd.Flags().StringVar(&stepID, "step-id", "", "screen step to edit") + _ = cmd.MarkFlagRequired("step-id") + return cmd +} + // runFlowEdit drives one Astra run against a screen step of a Flow and saves // the edited steps onto the Flow draft itself. func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID, stepID string) error { @@ -178,13 +230,13 @@ func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, cli if path, ok := screenshots["dark"]; ok { rt.Out.Field("Preview (dark)", path) } - rt.Out.Field("Keep designing", fmt.Sprintf("rc paywalls edit %s --step-id %s", draft.ID, stepID)) + rt.Out.Field("Keep designing", fmt.Sprintf("rc flows edit %s --step-id %s", draft.ID, stepID)) default: var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 409 { // The edits were made against a stale revision, so they are never replayed automatically. rt.Out.Warn("Nothing was saved: the flow changed during the run (dashboard or another client).") - rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc paywalls edit %s --step-id %s", draft.ID, stepID)) + rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc flows edit %s --step-id %s", draft.ID, stepID)) } else { rt.Out.Warn("Could not save the design to RevenueCat: " + err.Error()) rt.Out.Hint("Nothing was saved. Re-run the command to try again.") diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go index 516fb270..155a428f 100644 --- a/internal/cli/paywalls_ai_flow_test.go +++ b/internal/cli/paywalls_ai_flow_test.go @@ -25,7 +25,7 @@ const ( streamFailed = "data: {\"type\":\"run.failed\",\"session_id\":\"sess1\",\"error\":{\"code\":\"internal\",\"message\":\"boom\"},\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"partial\":true},\"components_localizations\":{}}}}\n\n" ) -func TestPaywallsEditStepID(t *testing.T) { +func TestFlowsEdit(t *testing.T) { tests := []struct { name string args []string @@ -100,9 +100,14 @@ func TestPaywallsEditStepID(t *testing.T) { wantErr: "drop --session", }, { - name: "flow id is required", + name: "step id is required", + args: []string{"wf_1"}, + wantErr: `required flag(s) "step-id" not set`, + }, + { + name: "flow id is required without a prompt for it", args: []string{"--step-id", "screen"}, - wantErr: "needs the flow id", + wantErr: "flow ID is required", }, } for _, tt := range tests { @@ -131,7 +136,7 @@ func TestPaywallsEditStepID(t *testing.T) { })) defer server.Close() - args := append([]string{"paywalls", "edit"}, tt.args...) + args := append([]string{"flows", "edit"}, tt.args...) args = append(args, "--prompt", "make it blue", "--base-url", server.URL, "--json", "--no-input") out, _, err := runProjectSetupCommand(t, server.URL, args...) if tt.wantErr != "" { From 6f6ec913df6f4d78d68a2ac2841b30c1019acf69 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 12:52:33 +0000 Subject: [PATCH 5/5] refactor(flows): use Get expand and Update for flows edit Co-Authored-By: Claude Sonnet 5.5 --- internal/cli/paywalls_ai_flow.go | 10 +++++----- internal/cli/paywalls_ai_flow_test.go | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go index ef05769b..3a0b3d41 100644 --- a/internal/cli/paywalls_ai_flow.go +++ b/internal/cli/paywalls_ai_flow.go @@ -131,7 +131,7 @@ func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID if err != nil { return err } - draft, err := client.Flows.GetDraft(ctx, projectID, flowID) + draft, err := client.Flows.Get(ctx, projectID, flowID, api.FlowExpandSteps) if err != nil { if api.IsNotFound(err) { return WithHint(err, "Pass the flow id: rc flows list --type paywall") @@ -216,7 +216,7 @@ func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID } } -func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, projectID string, draft *api.FlowDraft, stepID string, session *paywallAIFlowSession, event *paywallai.Event) error { +func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, projectID string, draft *api.Flow, stepID string, session *paywallAIFlowSession, event *paywallai.Event) error { screenshots := savePaywallScreenshots(rt, opts.sessionPath, event.ResultScreenshots) saved, err := saveEditedSteps(ctx, client, projectID, draft, event.EditedPaywallsByStepID) switch { @@ -260,7 +260,7 @@ func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, cli // saveEditedSteps writes every step the run edited back in one draft update, // guarded by the revision the run started from. -func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, draft *api.FlowDraft, edited map[string]paywallai.PaywallData) ([]string, error) { +func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, draft *api.Flow, edited map[string]paywallai.PaywallData) ([]string, error) { if len(edited) == 0 { return nil, nil } @@ -275,7 +275,7 @@ func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, } updated[stepID] = patch } - if _, err := client.Flows.UpdateDraft(ctx, projectID, draft.ID, api.UpdateFlowDraft{ + if _, err := client.Flows.Update(ctx, projectID, draft.ID, api.UpdateFlow{ Revision: draft.Revision, UpdatedSteps: updated, }); err != nil { @@ -306,7 +306,7 @@ func editedStepPatch(paywall paywallai.PaywallData) (json.RawMessage, error) { return json.Marshal(map[string]any{"paywall": pw}) } -func requireScreenStep(draft *api.FlowDraft, stepID string) error { +func requireScreenStep(draft *api.Flow, stepID string) error { raw, ok := draft.Steps[stepID] if !ok { return WithHint(fmt.Errorf("flow %s has no step %q", draft.ID, stepID), "List the flow's steps with: rc flows show "+draft.ID) diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go index 155a428f..76e7022e 100644 --- a/internal/cli/paywalls_ai_flow_test.go +++ b/internal/cli/paywalls_ai_flow_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -const flowDraftJSON = `{"id":"wf_1","object":"flow_draft","revision":4,"type":"paywall","steps":{` + +const flowDraftJSON = `{"id":"wf_1","object":"flow","revision":4,"type":"paywall","steps":{` + `"screen":{"type":"screen","name":"Main","paywall":{"offering_id":"ofrng1","components_config":{"old":true},"components_localizations":{},"haptics_enabled":false}},` + `"other":{"type":"screen","paywall":{"components_config":{"keep":true}}},` + `"auth":{"type":"auth"}}}` @@ -118,13 +118,13 @@ func TestFlowsEdit(t *testing.T) { var streamBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/projects/proj/flows/wf_1/draft": + case r.Method == http.MethodGet && r.URL.Path == "/projects/proj/flows/wf_1": _, _ = io.WriteString(w, flowDraftJSON) case r.Method == http.MethodPost && r.URL.Path == "/editor/v3/stream": streamed = true _ = json.NewDecoder(r.Body).Decode(&streamBody) _, _ = io.WriteString(w, tt.stream) - case r.Method == http.MethodPatch && r.URL.Path == "/projects/proj/flows/wf_1/draft": + case r.Method == http.MethodPatch && r.URL.Path == "/projects/proj/flows/wf_1": patchBody, _ = io.ReadAll(r.Body) patches++ w.WriteHeader(tt.saveStatus)