This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 3488d37034 fix(workflow): a plain save no longer clobbers is_public
(#8498)
3488d37034 is described below
commit 3488d37034acb70664c6554dd1bea792ad9590a3
Author: yangzhang75 <[email protected]>
AuthorDate: Fri Sep 11 02:30:11 2026 +0000
fix(workflow): a plain save no longer clobbers is_public (#8498)
### What changes were proposed in this PR?
Closes #8496. A regression from #8125: since then, every workflow's
second autosave (and every one after it) fails with 500, silently on the
operator canvas and as "Could not save" on the Form View.
`persistWorkflow` wrote `is_public` from the request. The frontend feeds
the saved row straight back as its metadata, and that row names the flag
`isPublic` while the rest of the frontend calls it `isPublished`, so the
very next save went out without the flag, the update wrote NULL into a
NOT NULL column, and the request failed. A stale `isPublic: false` on a
save could likewise un-publish a published workflow.
- Backend: `saveWorkflowFields` now writes name, description and content
only. Publishing stays with `/public` and `/private`, `default_view`
with `/set-default-view`, and the timestamps are not rewritten, so a
save can never clobber a concurrent change to any of them.
- Frontend: `WorkflowPersistService.persistWorkflow` no longer sends
`isPublic` (the endpoint does not read it, and the value is not reliably
known after the first save), and `WorkflowUtilService.parseWorkflowInfo`
carries a persist response's `isPublic` over to `isPublished`, so
metadata fed back from a save keeps the publish state instead of
dropping it.
The Form View stack is not blocked by this: #8455 touches none of these
files, and #8456 touches `workflow-persist.service.ts` only in
`createWorkflow` (adding `defaultView`), a different function; a dry-run
merge of the two is clean.
### Any related issues, documentation, discussions?
Closes #8496. Found while verifying #8455 on a flag-on instance (parent
#8011).
### How was this PR tested?
Backend: `WorkflowResourceSpec` gains two tests, a save carrying no flag
neither fails nor changes `is_public` after `/public`, and a save
carrying `false` does not un-publish; the existing default-view save
test was updated to send no flag, as the frontend does.
`WorkflowResourceSpec` and `PublishedCopySchemaSpec` pass (87 tests),
scalafmt clean.
Frontend: the persist spec asserts the save payload carries no
`isPublic` and that the response's `isPublic` comes back as
`isPublished`; `parseWorkflowInfo` gains tests for the carry-over and
for leaving a present `isPublished` alone. Full suite passes (5777),
changed lines fully covered, eslint, prettier and the production (AOT)
build pass.
End to end, against a running stack rebuilt with this change: the exact
second-save payload that returned 500 now returns 200 with `is_public`
unchanged; a create-through-persist with the new payload inserts with
`is_public = false`; and in a headless browser the Form View renames a
workflow twice with every `/api/workflow/persist` answering 200, no
`isPublic` key in any request body, and no "Could not save".
### Was this PR authored or co-authored using generative AI tooling?
Yes. Generated-by: Claude Code (Claude Fable 5.1, Anthropic).
Co-authored with Claude, reviewed line by line by the author before
submission.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FVvP3ttj22f9LB4p9u2anY
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Meng Wang <[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 b8bead4b0e..17cd7a11fa 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
@@ -489,10 +489,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
@@ -500,7 +503,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 88f429a3e6..d197808d00 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
@@ -1340,9 +1340,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)
@@ -1351,7 +1351,6 @@ class WorkflowResourceSpec
edit.setWid(wid)
edit.setName("param_survives_save_edited")
edit.setContent("{\"operators\":[],\"links\":[]}")
- edit.setIsPublic(false)
workflowResource.persistWorkflow(edit, sessionUser1)
assert(
@@ -1360,6 +1359,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;
}