@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +12 -2
  2. package/npm/lib/runtime.mjs +37 -0
  3. package/npm/scripts/postinstall.mjs +5 -1
  4. package/package.json +3 -2
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +230 -0
  7. package/skills/qingflow-app-user/agents/openai.yaml +4 -0
  8. package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
  9. package/skills/qingflow-app-user/references/environments.md +63 -0
  10. package/skills/qingflow-app-user/references/record-patterns.md +110 -0
  11. package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
  12. package/skills/qingflow-record-analysis/SKILL.md +253 -0
  13. package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
  14. package/skills/qingflow-record-analysis/references/analysis-gotchas.md +141 -0
  15. package/skills/qingflow-record-analysis/references/analysis-patterns.md +113 -0
  16. package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
  17. package/src/qingflow_mcp/__init__.py +1 -1
  18. package/src/qingflow_mcp/builder_facade/models.py +294 -1
  19. package/src/qingflow_mcp/builder_facade/service.py +2727 -235
  20. package/src/qingflow_mcp/server.py +7 -5
  21. package/src/qingflow_mcp/server_app_builder.py +80 -4
  22. package/src/qingflow_mcp/server_app_user.py +8 -182
  23. package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
  24. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
  25. package/src/qingflow_mcp/solution/executor.py +34 -7
  26. package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
  27. package/src/qingflow_mcp/tools/app_tools.py +1 -2
  28. package/src/qingflow_mcp/tools/approval_tools.py +357 -75
  29. package/src/qingflow_mcp/tools/directory_tools.py +158 -28
  30. package/src/qingflow_mcp/tools/record_tools.py +1954 -973
  31. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  32. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -28,14 +28,16 @@ def build_server() -> FastMCP:
28
28
  instructions=(
29
29
  "Use auth_login first, then workspace_list and workspace_select. "
30
30
  "All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
31
+ "For analytics, use record_schema_get first, let the model build field_id-based DSL, "
32
+ "then call record_analyze. For operational record reads, use record_schema_get first, then record_list or record_get. "
33
+ "For writes, use record_schema_get and then record_write with mode=plan or apply.\n\n"
31
34
  "Task Center (待办/已办) handling:\n"
32
- "- Use task_statistics to get counts of pending tasks (todo_count), timeouts, urged, etc.\n"
33
- "- Use task_list to query tasks. Type values: 1=todo (待办), 2=initiated (我发起的), 3=cc (抄送), 5=done (已办).\n"
34
- "- Use task_list_grouped to get tasks grouped by form/worksheet.\n"
35
+ "- Use task_summary to get headline counts.\n"
36
+ "- Use task_list for flat task browsing with task_box and flow_status.\n"
37
+ "- Use task_facets when worksheet or workflow-node buckets matter.\n"
35
38
  "- Use task_mark_read to mark a specific task as read.\n"
36
39
  "- Use task_urge to send an urgent reminder for a pending task.\n"
37
- "- Process status values: 1=all, 2=processing, 3=passed, 4=refused, 5=need_supply, 6=urged, 7=timeout, 8=pre_timeout, 9=unread.\n"
38
- "- After identifying the exact task node and record, use record_approve, record_reject, record_rollback, record_transfer, record_reassign, or record_countersign as needed."
40
+ "- After identifying the exact task node and record, use task_approve, task_reject, task_rollback, or task_transfer as needed."
39
41
  ),
40
42
  )
41
43
  sessions = SessionStore()
@@ -17,11 +17,15 @@ def build_builder_server() -> FastMCP:
17
17
  instructions=(
18
18
  "Use this server for AI-native Qingflow builder workflows. "
19
19
  "Follow the resource path resolve -> summary read -> plan -> apply -> attach -> publish_verify. "
20
- "Use package_resolve/package_list and app_resolve to locate resources, "
20
+ "Use builder_tool_contract when you need a machine-readable contract, aliases, allowed enums, or a minimal valid example for a public builder tool. "
21
+ "If creating a new package may be appropriate, ask the user to confirm package creation before calling package_create; otherwise use package_resolve/package_list and app_resolve to locate resources, "
21
22
  "app_read_summary/app_read_fields/app_read_layout_summary/app_read_views_summary/app_read_flow_summary for compact reads, "
23
+ "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
22
24
  "app_schema_plan/app_layout_plan/app_flow_plan/app_views_plan before writes when the target patch is non-trivial, "
23
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply to execute normalized patches, "
24
- "package_attach_app to attach apps to packages, and app_publish_verify to publish and verify. "
25
+ "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply to execute normalized patches; these apply tools publish by default unless publish=false. "
26
+ "Use package_attach_app to attach apps to packages, and app_publish_verify for explicit final publish verification. "
27
+ "For workflow edits, prefer preset plus explicit patching over generating a full custom graph from scratch, and declare node assignees and editable fields explicitly. "
28
+ "If builder writes are blocked by the current user's own edit lock, use app_release_edit_lock_if_mine with the lock owner details from the failed result. "
25
29
  "Do not handcraft internal solution payloads or rely on build_id/stage/repair."
26
30
  ),
27
31
  )
@@ -124,6 +128,57 @@ def build_builder_server() -> FastMCP:
124
128
  def package_resolve(profile: str = DEFAULT_PROFILE, package_name: str = "") -> dict:
125
129
  return ai_builder.package_resolve(profile=profile, package_name=package_name)
126
130
 
131
+ @server.tool()
132
+ def builder_tool_contract(tool_name: str = "") -> dict:
133
+ return ai_builder.builder_tool_contract(tool_name=tool_name)
134
+
135
+ @server.tool()
136
+ def package_create(profile: str = DEFAULT_PROFILE, package_name: str = "") -> dict:
137
+ return ai_builder.package_create(profile=profile, package_name=package_name)
138
+
139
+ @server.tool()
140
+ def member_search(
141
+ profile: str = DEFAULT_PROFILE,
142
+ query: str = "",
143
+ page_num: int = 1,
144
+ page_size: int = 20,
145
+ contain_disable: bool = False,
146
+ ) -> dict:
147
+ return ai_builder.member_search(
148
+ profile=profile,
149
+ query=query,
150
+ page_num=page_num,
151
+ page_size=page_size,
152
+ contain_disable=contain_disable,
153
+ )
154
+
155
+ @server.tool()
156
+ def role_search(
157
+ profile: str = DEFAULT_PROFILE,
158
+ keyword: str = "",
159
+ page_num: int = 1,
160
+ page_size: int = 20,
161
+ ) -> dict:
162
+ return ai_builder.role_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
163
+
164
+ @server.tool()
165
+ def role_create(
166
+ profile: str = DEFAULT_PROFILE,
167
+ role_name: str = "",
168
+ member_uids: list[int] | None = None,
169
+ member_emails: list[str] | None = None,
170
+ member_names: list[str] | None = None,
171
+ role_icon: str = "ex-user-outlined",
172
+ ) -> dict:
173
+ return ai_builder.role_create(
174
+ profile=profile,
175
+ role_name=role_name,
176
+ member_uids=member_uids or [],
177
+ member_emails=member_emails or [],
178
+ member_names=member_names or [],
179
+ role_icon=role_icon,
180
+ )
181
+
127
182
  @server.tool()
128
183
  def package_attach_app(
129
184
  profile: str = DEFAULT_PROFILE,
@@ -133,6 +188,20 @@ def build_builder_server() -> FastMCP:
133
188
  ) -> dict:
134
189
  return ai_builder.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title)
135
190
 
191
+ @server.tool()
192
+ def app_release_edit_lock_if_mine(
193
+ profile: str = DEFAULT_PROFILE,
194
+ app_key: str = "",
195
+ lock_owner_email: str = "",
196
+ lock_owner_name: str = "",
197
+ ) -> dict:
198
+ return ai_builder.app_release_edit_lock_if_mine(
199
+ profile=profile,
200
+ app_key=app_key,
201
+ lock_owner_email=lock_owner_email,
202
+ lock_owner_name=lock_owner_name,
203
+ )
204
+
136
205
  @server.tool()
137
206
  def app_resolve(
138
207
  profile: str = DEFAULT_PROFILE,
@@ -242,6 +311,7 @@ def build_builder_server() -> FastMCP:
242
311
  app_name: str = "",
243
312
  app_title: str = "",
244
313
  create_if_missing: bool = False,
314
+ publish: bool = True,
245
315
  add_fields: list[dict] | None = None,
246
316
  update_fields: list[dict] | None = None,
247
317
  remove_fields: list[dict] | None = None,
@@ -253,6 +323,7 @@ def build_builder_server() -> FastMCP:
253
323
  app_name=app_name,
254
324
  app_title=app_title,
255
325
  create_if_missing=create_if_missing,
326
+ publish=publish,
256
327
  add_fields=add_fields or [],
257
328
  update_fields=update_fields or [],
258
329
  remove_fields=remove_fields or [],
@@ -263,15 +334,17 @@ def build_builder_server() -> FastMCP:
263
334
  profile: str = DEFAULT_PROFILE,
264
335
  app_key: str = "",
265
336
  mode: str = "merge",
337
+ publish: bool = True,
266
338
  sections: list[dict] | None = None,
267
339
  ) -> dict:
268
- return ai_builder.app_layout_apply(profile=profile, app_key=app_key, mode=mode, sections=sections or [])
340
+ return ai_builder.app_layout_apply(profile=profile, app_key=app_key, mode=mode, publish=publish, sections=sections or [])
269
341
 
270
342
  @server.tool()
271
343
  def app_flow_apply(
272
344
  profile: str = DEFAULT_PROFILE,
273
345
  app_key: str = "",
274
346
  mode: str = "replace",
347
+ publish: bool = True,
275
348
  nodes: list[dict] | None = None,
276
349
  transitions: list[dict] | None = None,
277
350
  ) -> dict:
@@ -279,6 +352,7 @@ def build_builder_server() -> FastMCP:
279
352
  profile=profile,
280
353
  app_key=app_key,
281
354
  mode=mode,
355
+ publish=publish,
282
356
  nodes=nodes or [],
283
357
  transitions=transitions or [],
284
358
  )
@@ -287,12 +361,14 @@ def build_builder_server() -> FastMCP:
287
361
  def app_views_apply(
288
362
  profile: str = DEFAULT_PROFILE,
289
363
  app_key: str = "",
364
+ publish: bool = True,
290
365
  upsert_views: list[dict] | None = None,
291
366
  remove_views: list[str] | None = None,
292
367
  ) -> dict:
293
368
  return ai_builder.app_views_apply(
294
369
  profile=profile,
295
370
  app_key=app_key,
371
+ publish=publish,
296
372
  upsert_views=upsert_views or [],
297
373
  remove_views=remove_views or [],
298
374
  )
@@ -18,8 +18,11 @@ def build_user_server() -> FastMCP:
18
18
  server = FastMCP(
19
19
  "Qingflow App User MCP",
20
20
  instructions=(
21
- "Use this server for Qingflow record queries, record writes, task center operations, "
22
- "directory lookups, and approval actions. Avoid builder-side app or schema changes here."
21
+ "Use this server for Qingflow operational workflows with a schema-first path. "
22
+ "For records, start with record_schema_get, then choose record_list, record_get, or record_write. "
23
+ "For analytics, switch to record_schema_get and record_analyze. "
24
+ "For task center, use task_summary, task_list, and task_facets before any explicit task action. "
25
+ "Avoid builder-side app or schema changes here."
23
26
  ),
24
27
  )
25
28
  sessions = SessionStore()
@@ -136,189 +139,12 @@ def build_user_server() -> FastMCP:
136
139
  bucket_type=bucket_type,
137
140
  path_id=path_id,
138
141
  file_related_url=file_related_url,
139
- )
142
+ )
140
143
 
141
144
  RecordTools(sessions, backend).register(server)
142
145
  DirectoryTools(sessions, backend).register(server)
143
-
144
- @server.tool()
145
- def record_comment_add(
146
- profile: str = DEFAULT_PROFILE,
147
- app_key: str = "",
148
- apply_id: int = 0,
149
- payload: dict | None = None,
150
- ) -> dict:
151
- return approvals.record_comment_add(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
152
-
153
- @server.tool()
154
- def record_comment_list(
155
- profile: str = DEFAULT_PROFILE,
156
- app_key: str = "",
157
- apply_id: int = 0,
158
- page_size: int = 20,
159
- list_type: int | None = None,
160
- page_num: int | None = 1,
161
- ) -> dict:
162
- return approvals.record_comment_list(
163
- profile=profile,
164
- app_key=app_key,
165
- apply_id=apply_id,
166
- page_size=page_size,
167
- list_type=list_type,
168
- page_num=page_num,
169
- )
170
-
171
- @server.tool()
172
- def record_comment_mention_candidates(
173
- profile: str = DEFAULT_PROFILE,
174
- app_key: str = "",
175
- apply_id: int = 0,
176
- page_size: int = 20,
177
- page_num: int = 1,
178
- list_type: int | None = None,
179
- keyword: str | None = None,
180
- ) -> dict:
181
- return approvals.record_comment_mention_candidates(
182
- profile=profile,
183
- app_key=app_key,
184
- apply_id=apply_id,
185
- page_size=page_size,
186
- page_num=page_num,
187
- list_type=list_type,
188
- keyword=keyword,
189
- )
190
-
191
- @server.tool()
192
- def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
193
- return approvals.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=apply_id)
194
-
195
- @server.tool()
196
- def record_comment_stats(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
197
- return approvals.record_comment_stats(profile=profile, app_key=app_key, apply_id=apply_id)
198
-
199
- @server.tool()
200
- def task_list(
201
- profile: str = DEFAULT_PROFILE,
202
- type: int = 1,
203
- process_status: int = 1,
204
- app_key: str | None = None,
205
- node_id: int | None = None,
206
- search_key: str | None = None,
207
- page_num: int = 1,
208
- page_size: int = 20,
209
- create_time_asc: bool | None = None,
210
- ) -> dict:
211
- return tasks.task_list(
212
- profile=profile,
213
- type=type,
214
- process_status=process_status,
215
- app_key=app_key,
216
- node_id=node_id,
217
- search_key=search_key,
218
- page_num=page_num,
219
- page_size=page_size,
220
- create_time_asc=create_time_asc,
221
- )
222
-
223
- @server.tool()
224
- def task_list_grouped(
225
- profile: str = DEFAULT_PROFILE,
226
- type: int = 1,
227
- process_status: int = 1,
228
- app_key: str | None = None,
229
- node_id: int | None = None,
230
- search_key: str | None = None,
231
- page_num: int = 1,
232
- page_size: int = 20,
233
- ) -> dict:
234
- return tasks.task_list_grouped(
235
- profile=profile,
236
- type=type,
237
- process_status=process_status,
238
- app_key=app_key,
239
- node_id=node_id,
240
- search_key=search_key,
241
- page_num=page_num,
242
- page_size=page_size,
243
- )
244
-
245
- @server.tool()
246
- def task_statistics(profile: str = DEFAULT_PROFILE, app_key: str | None = None) -> dict:
247
- return tasks.task_statistics(profile=profile, app_key=app_key)
248
-
249
- @server.tool()
250
- def task_urge(profile: str = DEFAULT_PROFILE, app_key: str = "", row_record_id: int = 0) -> dict:
251
- return tasks.task_urge(profile=profile, app_key=app_key, row_record_id=row_record_id)
252
-
253
- @server.tool(description=approvals._high_risk_tool_description(operation="approve", target="workflow task"))
254
- def task_approve(
255
- profile: str = DEFAULT_PROFILE,
256
- app_key: str = "",
257
- apply_id: int = 0,
258
- payload: dict | None = None,
259
- ) -> dict:
260
- return approvals.record_approve(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
261
-
262
- @server.tool(description=approvals._high_risk_tool_description(operation="reject", target="workflow task"))
263
- def task_reject(
264
- profile: str = DEFAULT_PROFILE,
265
- app_key: str = "",
266
- apply_id: int = 0,
267
- payload: dict | None = None,
268
- ) -> dict:
269
- return approvals.record_reject(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
270
-
271
- @server.tool()
272
- def task_rollback_candidates(
273
- profile: str = DEFAULT_PROFILE,
274
- app_key: str = "",
275
- apply_id: int = 0,
276
- audit_node_id: int = 0,
277
- ) -> dict:
278
- return approvals.record_rollback_candidates(
279
- profile=profile,
280
- app_key=app_key,
281
- apply_id=apply_id,
282
- audit_node_id=audit_node_id,
283
- )
284
-
285
- @server.tool()
286
- def task_rollback(
287
- profile: str = DEFAULT_PROFILE,
288
- app_key: str = "",
289
- apply_id: int = 0,
290
- payload: dict | None = None,
291
- ) -> dict:
292
- return approvals.record_rollback(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
293
-
294
- @server.tool()
295
- def task_transfer_candidates(
296
- profile: str = DEFAULT_PROFILE,
297
- app_key: str = "",
298
- apply_id: int = 0,
299
- page_size: int = 20,
300
- page_num: int = 1,
301
- audit_node_id: int = 0,
302
- keyword: str | None = None,
303
- ) -> dict:
304
- return approvals.record_transfer_candidates(
305
- profile=profile,
306
- app_key=app_key,
307
- apply_id=apply_id,
308
- page_size=page_size,
309
- page_num=page_num,
310
- audit_node_id=audit_node_id,
311
- keyword=keyword,
312
- )
313
-
314
- @server.tool()
315
- def task_transfer(
316
- profile: str = DEFAULT_PROFILE,
317
- app_key: str = "",
318
- apply_id: int = 0,
319
- payload: dict | None = None,
320
- ) -> dict:
321
- return approvals.record_transfer(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
146
+ approvals.register(server)
147
+ tasks.register(server)
322
148
 
323
149
  return server
324
150
 
@@ -249,7 +249,7 @@ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any],
249
249
  sub_question, next_temp_id = build_question(subfield, next_temp_id)
250
250
  sub_questions.append(sub_question)
251
251
  question["subQuestions"] = sub_questions
252
- question["innerQuestions"] = deepcopy(sub_questions)
252
+ question["innerQuestions"] = [deepcopy(sub_questions)]
253
253
  question["queDefaultValues"] = {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}
254
254
  return question, next_temp_id
255
255
 
@@ -36,6 +36,11 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
36
36
  actions: list[dict[str, Any]] = []
37
37
  seen_node_ids: set[str] = set()
38
38
  created_extra_branch_lanes: set[str] = set()
39
+ start_node_ids = {
40
+ node.node_id
41
+ for node in workflow.nodes
42
+ if node.node_type == WorkflowNodeType.start
43
+ }
39
44
  for node in workflow.nodes:
40
45
  if node.node_type == WorkflowNodeType.start:
41
46
  seen_node_ids.add(node.node_id)
@@ -69,7 +74,7 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
69
74
  "auditNodeName": node.name,
70
75
  "type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
71
76
  "dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
72
- "prevNodeRef": _prev_node_ref(node, branch_lane_ref),
77
+ "prevNodeRef": _prev_node_ref(node, branch_lane_ref, start_node_ids),
73
78
  "auditUserInfos": _build_audit_user_infos(node)
74
79
  if node.node_type in {WorkflowNodeType.audit, WorkflowNodeType.fill, WorkflowNodeType.copy}
75
80
  else None,
@@ -100,17 +105,31 @@ def _build_audit_user_infos(node) -> dict[str, Any]:
100
105
  role_refs = assignees.get("role_refs") or []
101
106
  if role_refs:
102
107
  audit_user_infos["role_refs"] = [role_ref for role_ref in role_refs if role_ref]
108
+ role_entries = assignees.get("role_entries") or []
109
+ if role_entries:
110
+ audit_user_infos["role"] = [
111
+ {
112
+ "roleId": int(entry.get("roleId") or entry.get("role_id")),
113
+ "roleName": entry.get("roleName") or entry.get("role_name") or str(entry.get("roleId") or entry.get("role_id")),
114
+ "roleIcon": entry.get("roleIcon") or entry.get("role_icon") or "ex-user-outlined",
115
+ "beingFrontendConfig": True,
116
+ }
117
+ for entry in role_entries
118
+ if isinstance(entry, dict) and isinstance(entry.get("roleId") or entry.get("role_id"), int) and int(entry.get("roleId") or entry.get("role_id")) > 0
119
+ ]
103
120
  include_sub_departs = assignees.get("include_sub_departs")
104
121
  if include_sub_departs is not None:
105
122
  audit_user_infos["includeSubDeparts"] = bool(include_sub_departs)
106
123
  return audit_user_infos
107
124
 
108
125
 
109
- def _prev_node_ref(node, branch_lane_ref: str | None) -> str:
126
+ def _prev_node_ref(node, branch_lane_ref: str | None, start_node_ids: set[str]) -> str:
110
127
  if branch_lane_ref:
111
128
  if node.parent_node_id and node.parent_node_id != node.branch_parent_id:
112
129
  return node.parent_node_id
113
130
  return branch_lane_ref
131
+ if node.parent_node_id in start_node_ids:
132
+ return "__applicant__"
114
133
  return node.parent_node_id or "__applicant__"
115
134
 
116
135
 
@@ -415,13 +415,13 @@ class SolutionExecutor:
415
415
  current_nodes = _coerce_workflow_nodes(existing_nodes)
416
416
  existing_nodes_by_name = {
417
417
  node.get("auditNodeName"): int(node_id)
418
- for node_id, node in existing_nodes.items()
418
+ for node_id, node in current_nodes.items()
419
419
  if isinstance(node, dict) and node.get("auditNodeName")
420
420
  }
421
421
  applicant_node_id = next(
422
422
  (
423
423
  int(node_id)
424
- for node_id, node in existing_nodes.items()
424
+ for node_id, node in current_nodes.items()
425
425
  if isinstance(node, dict) and node.get("type") == 0 and node.get("dealType") == 3
426
426
  ),
427
427
  None,
@@ -429,11 +429,24 @@ class SolutionExecutor:
429
429
  if applicant_node_id is not None:
430
430
  node_artifacts.setdefault("__applicant__", applicant_node_id)
431
431
 
432
- current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
433
- global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
434
- global_settings.update(entity.workflow_plan["global_settings"])
435
- global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
436
- self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
432
+ desired_global_settings = deepcopy(entity.workflow_plan["global_settings"])
433
+ explicit_global_settings = _has_explicit_workflow_global_settings(desired_global_settings)
434
+ current_global_settings: dict[str, Any] = {}
435
+ if explicit_global_settings:
436
+ current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
437
+ else:
438
+ try:
439
+ current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
440
+ except (QingflowApiError, RuntimeError) as error:
441
+ api_error = QingflowApiError(**_coerce_nested_error_payload(error))
442
+ if api_error.http_status != 404:
443
+ raise
444
+ current_global_settings = {}
445
+ if explicit_global_settings:
446
+ global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
447
+ global_settings.update(desired_global_settings)
448
+ global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
449
+ self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
437
450
  for action in entity.workflow_plan["actions"]:
438
451
  if action["action"] == "create_sub_branch" and node_artifacts.get(action["node_id"]) is not None:
439
452
  continue
@@ -2046,6 +2059,20 @@ def _find_created_sub_branch_lane_id(
2046
2059
  return candidates[0] if candidates else None
2047
2060
 
2048
2061
 
2062
+ def _has_explicit_workflow_global_settings(global_settings: dict[str, Any] | None) -> bool:
2063
+ if not isinstance(global_settings, dict):
2064
+ return False
2065
+ for key, value in global_settings.items():
2066
+ if key == "editVersionNo":
2067
+ continue
2068
+ if value is None:
2069
+ continue
2070
+ if isinstance(value, (list, dict)) and not value:
2071
+ continue
2072
+ return True
2073
+ return False
2074
+
2075
+
2049
2076
  def _is_navigation_plugin_unavailable(error: QingflowApiError) -> bool:
2050
2077
  try:
2051
2078
  backend_code = int(error.backend_code)