This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch release/v1.3
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/release/v1.3 by this push:
     new 84b6c9086e fix(workflow, v1.3): a plain save no longer clobbers 
is_public (#8500)
84b6c9086e is described below

commit 84b6c9086ef2a2627098e21ee222e3c4567d6fbb
Author: Meng Wang <[email protected]>
AuthorDate: Fri Sep 11 03:19:00 2026 +0000

    fix(workflow, v1.3): a plain save no longer clobbers is_public (#8500)
    
    ### What changes were proposed in this PR?
    
    Backport of #8498 to `release/v1.3`: a clean cherry-pick of its squash
    commit, no adaptations — the backport commit reuses the squash message
    and author, as the automated fast path would. See #8498 for the full
    analysis (a regression from #8125 — every second autosave failed with
    500, and a stale `isPublic: false` could un-publish a workflow).
    
    Opened manually by the v1.3 release manager: the automated post-merge
    direct push to release branches is currently rejected by the Merge Queue
    ruleset (#8377; fix in review in #8379), so this backport goes through
    the merge queue instead. #8498's pre-merge backport check for this
    branch (`apply-check` and the `backport (release/v1.3)` matrix) was
    green.
    
    Source: 3488d37034acb70664c6554dd1bea792ad9590a3
    
    ### Any related issues, documentation, discussions?
    
    Backport of #8498. Originally linked #8496.
    
    ### How was this PR tested?
    
    The change is identical to #8498, which carries the tests (backend
    `WorkflowResourceSpec`, frontend persist/parse specs) and an end-to-end
    verification; the backport tree is verified byte-identical to
    cherry-picking the squash commit onto `release/v1.3`, and release-branch
    CI runs the full matrix on this PR.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (claude-fable-5)
    
    Co-authored-by: yangzhang75 <[email protected]>
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 .../dashboard/user/workflow/WorkflowResource.scala | 12 ++++---
 .../dashboard/file/WorkflowResourceSpec.scala      | 40 +++++++++++++++++++---
 .../workflow-persist.service.spec.ts               | 10 ++++--
 .../workflow-persist/workflow-persist.service.ts   |  5 ++-
 .../util/workflow-util.service.spec.ts             | 15 ++++++++
 .../workflow-graph/util/workflow-util.service.ts   |  8 +++++
 6 files changed, 77 insertions(+), 13 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
index d2c7d62731..be8347e5e7 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
@@ -481,10 +481,13 @@ class WorkflowResource extends LazyLogging {
   }
 
   /**
-    * Persists a plain save by updating only the fields the client sends
-    * (name/description/content/is_public). It deliberately leaves 
`default_view` untouched --
-    * that column is owned by /set-default-view alone -- so a save can never 
clobber a
-    * concurrent change. Timestamps are likewise not rewritten here.
+    * Persists a plain save by updating only what a save is: name, description 
and content.
+    * `is_public` is not written here. Publishing has its own endpoints 
(/public, /private), and
+    * a save payload does not reliably carry the flag: the frontend feeds the 
saved row straight
+    * back as its metadata, where the flag has another name, so the very next 
autosave arrives
+    * without it. Writing that null violated the column's NOT NULL constraint 
and every second
+    * save failed with 500. `default_view` is likewise owned by 
/set-default-view alone, and the
+    * timestamps are not rewritten here, so a save can never clobber a 
concurrent change to either.
     */
   private def saveWorkflowFields(workflow: Workflow): Unit = {
     context
@@ -492,7 +495,6 @@ class WorkflowResource extends LazyLogging {
       .set(WORKFLOW.NAME, workflow.getName)
       .set(WORKFLOW.DESCRIPTION, workflow.getDescription)
       .set(WORKFLOW.CONTENT, workflow.getContent)
-      .set(WORKFLOW.IS_PUBLIC, workflow.getIsPublic)
       .where(WORKFLOW.WID.eq(workflow.getWid))
       .execute()
   }
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
index 1fd9c406f3..48635ab298 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
@@ -1380,9 +1380,9 @@ class WorkflowResourceSpec
     assert(defaultView(wid) == DefaultViewEnum.CANVAS)
   }
 
-  // A plain save (persistWorkflow) only writes the fields the client sends -- 
name,
-  // description, content, is_public -- and never `default_view`, so saving 
the canvas must
-  // not reset the default view. The edit payload mirrors what the frontend 
sends.
+  // A plain save (persistWorkflow) only writes name, description and content 
-- never
+  // `default_view`, so saving the canvas must not reset the default view. The 
edit payload mirrors
+  // what the frontend sends.
   it should "survive a subsequent save of the workflow" in {
     val wid = persistFreshWorkflow("param_survives_save")
     workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), 
sessionUser1)
@@ -1391,7 +1391,6 @@ class WorkflowResourceSpec
     edit.setWid(wid)
     edit.setName("param_survives_save_edited")
     edit.setContent("{\"operators\":[],\"links\":[]}")
-    edit.setIsPublic(false)
     workflowResource.persistWorkflow(edit, sessionUser1)
 
     assert(
@@ -1400,6 +1399,39 @@ class WorkflowResourceSpec
     )
   }
 
+  // The frontend feeds the saved row back as its metadata, where the publish 
flag has another
+  // name, so the very next autosave arrives with isPublic unset. A save must 
neither fail on that
+  // (the column is NOT NULL, and writing the null made every second save a 
500) nor rewrite the
+  // flag: publishing is /public and /private's job alone.
+  "/persist API" should "neither fail nor change is_public when the save 
carries no flag" in {
+    val wid = persistFreshWorkflow("persist_keeps_public")
+    workflowResource.makePublic(wid, sessionUser1)
+
+    val edit = new Workflow()
+    edit.setWid(wid)
+    edit.setName("persist_keeps_public_edited")
+    edit.setContent("{\"operators\":[],\"links\":[]}")
+    // isPublic deliberately left null, exactly as the frontend's second save 
sends it
+    val saved = workflowResource.persistWorkflow(edit, sessionUser1)
+
+    assert(saved.getName == "persist_keeps_public_edited")
+    assert(saved.getIsPublic, "a plain save must not touch the publish flag")
+  }
+
+  it should "not un-publish a workflow when the save says isPublic = false" in 
{
+    val wid = persistFreshWorkflow("persist_ignores_flag")
+    workflowResource.makePublic(wid, sessionUser1)
+
+    val edit = new Workflow()
+    edit.setWid(wid)
+    edit.setName("persist_ignores_flag_edited")
+    edit.setContent("{\"operators\":[],\"links\":[]}")
+    edit.setIsPublic(false)
+    val saved = workflowResource.persistWorkflow(edit, sessionUser1)
+
+    assert(saved.getIsPublic, "a stale flag on a save must not un-publish the 
workflow")
+  }
+
   // A biologist's path is hub -> clone -> use, so a copy has to stay usable.
   it should "be inherited by a duplicated workflow" in {
     val wid = persistFreshWorkflow("param_source")
diff --git 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
index a5e4463037..1ae30d331c 100644
--- 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
+++ 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
@@ -202,19 +202,23 @@ describe("WorkflowPersistService", () => {
 
       const req = 
httpTestingController.expectOne(`${API}/${WORKFLOW_PERSIST_URL}`);
       expect(req.request.method).toBe("POST");
+      // The publish flag is not part of a save: the endpoint does not read 
it, and sending a
+      // stale copy is what used to null the column after the first save.
       expect(req.request.body).toEqual({
         wid: 9,
         name: "my wf",
         description: "a description",
         content: JSON.stringify(validContent),
-        isPublic: true,
       });
 
-      req.flush({ wid: 9, name: "my wf", content: '{"operators":[]}' });
+      // The saved row comes back with the flag under the backend's name; the 
response the
+      // caller sees carries it as isPublished, so metadata fed back from a 
save stays complete.
+      req.flush({ wid: 9, name: "my wf", content: '{"operators":[]}', 
isPublic: true });
 
       // valid workflow -> no error notification, and string content is parsed
       expect(errorSpy).not.toHaveBeenCalled();
       expect(result?.content).toEqual({ operators: [] });
+      expect(result?.isPublished).toBe(1);
     });
 
     it("persistWorkflow notifies the user when the workflow is broken but 
still POSTs", () => {
@@ -235,7 +239,7 @@ describe("WorkflowPersistService", () => {
       );
 
       const req = 
httpTestingController.expectOne(`${API}/${WORKFLOW_PERSIST_URL}`);
-      expect(req.request.body.isPublic).toBe(false);
+      expect("isPublic" in req.request.body).toBe(false);
       req.flush({ wid: 1, name: "broken", content: '{"operators":[]}' });
     });
 
diff --git 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
index 8e2203addd..9b8f4741bd 100644
--- 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
+++ 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
@@ -75,13 +75,16 @@ export class WorkflowPersistService {
       );
     }
 
+    // A save carries name, description and content only. The publish flag is 
not sent: the
+    // backend does not read it on this endpoint (publishing goes through 
/public and /private),
+    // and it is not reliably known here anyway, since the metadata fed back 
after a save names
+    // it differently (see WorkflowUtilService.parseWorkflowInfo).
     return this.http
       
.post<Workflow>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_PERSIST_URL}`, {
         wid: workflow.wid,
         name: workflow.name,
         description: workflow.description,
         content: JSON.stringify(workflow.content),
-        isPublic: workflow.isPublished,
       })
       .pipe(
         filter((updatedWorkflow: Workflow) => updatedWorkflow != null),
diff --git 
a/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.spec.ts
 
b/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.spec.ts
index 4146e0704a..61c04a0825 100644
--- 
a/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.spec.ts
+++ 
b/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.spec.ts
@@ -219,6 +219,21 @@ describe("WorkflowUtilService", () => {
     expect(parsed.content).toBe(content);
   });
 
+  // The persist endpoint returns the stored row, which names the publish flag 
isPublic; the rest
+  // of the frontend knows it as isPublished. Without the carry-over, a save 
fed back as metadata
+  // lost the flag, and the next save went out without it.
+  it("should carry the persist response's isPublic over to isPublished", () => 
{
+    const fromPersist = { wid: 1, name: "n", content: "{}", isPublic: true } 
as unknown as Workflow;
+
+    
expect(WorkflowUtilService.parseWorkflowInfo(fromPersist).isPublished).toBe(1);
+  });
+
+  it("should leave an isPublished the payload already carries alone", () => {
+    const fromRetrieve = { wid: 1, name: "n", content: "{}", isPublished: 0, 
isPublic: true } as unknown as Workflow;
+
+    
expect(WorkflowUtilService.parseWorkflowInfo(fromRetrieve).isPublished).toBe(0);
+  });
+
   it("should create a fresh comment box at the default position", () => {
     const commentBox = workflowUtilService.getNewCommentBox();
 
diff --git 
a/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.ts
 
b/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.ts
index 64681965b4..0760b8841d 100644
--- 
a/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.ts
+++ 
b/frontend/src/app/workspace/service/workflow-graph/util/workflow-util.service.ts
@@ -182,6 +182,14 @@ export class WorkflowUtilService {
     if (workflow != null && typeof workflow.content === "string") {
       workflow.content = jsonCast<WorkflowContent>(workflow.content);
     }
+    // The persist endpoint answers with the stored row, whose publish flag is 
named isPublic;
+    // every other workflow endpoint, and the Workflow type, call it 
isPublished. Carry it across,
+    // or the metadata a save feeds back would silently drop the publish state 
until the next
+    // full load.
+    const stored = workflow as Workflow & { isPublic?: boolean };
+    if (workflow != null && workflow.isPublished === undefined && 
stored.isPublic !== undefined) {
+      workflow.isPublished = Number(stored.isPublic);
+    }
     return workflow;
   }
 

Reply via email to