@josephyan/qingflow-app-user-mcp 0.2.0-beta.6 → 0.2.0-beta.8

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.6
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.8
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.6 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.8 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b6"
7
+ version = "0.2.0b8"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b6"
5
+ __version__ = "0.2.0b8"
@@ -332,6 +332,8 @@ def _normalize_field_payload(value: Any) -> Any:
332
332
  if not isinstance(value, dict):
333
333
  return value
334
334
  payload = dict(value)
335
+ if "fields" in payload and "subfields" not in payload:
336
+ payload["subfields"] = payload.pop("fields")
335
337
  raw_type = payload.get("type")
336
338
  if isinstance(raw_type, str):
337
339
  normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
@@ -4,6 +4,7 @@ from copy import deepcopy
4
4
  from dataclasses import dataclass
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import tempfile
8
9
  from typing import Any
9
10
  from uuid import uuid4
@@ -210,6 +211,115 @@ class AiBuilderFacade:
210
211
  "attached": attached,
211
212
  }
212
213
 
214
+ def app_release_edit_lock_if_mine(
215
+ self,
216
+ *,
217
+ profile: str,
218
+ app_key: str,
219
+ lock_owner_email: str = "",
220
+ lock_owner_name: str = "",
221
+ ) -> JSONObject:
222
+ normalized_args = {
223
+ "app_key": app_key,
224
+ "lock_owner_email": lock_owner_email,
225
+ "lock_owner_name": lock_owner_name,
226
+ }
227
+ session_profile = self.apps.sessions.get_profile(profile)
228
+ if session_profile is None:
229
+ return _failed(
230
+ "AUTH_REQUIRED",
231
+ "auth profile is required before releasing an app edit lock",
232
+ normalized_args=normalized_args,
233
+ recoverable=False,
234
+ suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
235
+ )
236
+ identity = self._resolve_current_user_identity(profile=profile)
237
+ current_email = str(identity.get("email") or "").strip().lower()
238
+ current_name = str(identity.get("nick_name") or "").strip()
239
+ requested_owner_email = str(lock_owner_email or "").strip().lower()
240
+ requested_owner_name = str(lock_owner_name or "").strip()
241
+ if not requested_owner_email and not requested_owner_name:
242
+ return _failed(
243
+ "EDIT_LOCK_OWNER_UNKNOWN",
244
+ "lock owner could not be verified; refuse to release edit lock blindly",
245
+ normalized_args=normalized_args,
246
+ recoverable=False,
247
+ details={
248
+ "current_user_email": identity.get("email"),
249
+ "current_user_name": identity.get("nick_name"),
250
+ },
251
+ suggested_next_call=None,
252
+ )
253
+ owner_matches = True
254
+ if requested_owner_email:
255
+ owner_matches = bool(current_email) and current_email == requested_owner_email
256
+ elif requested_owner_name:
257
+ owner_matches = bool(current_name) and current_name == requested_owner_name
258
+ if not owner_matches:
259
+ return _failed(
260
+ "EDIT_LOCK_HELD_BY_OTHER_USER",
261
+ "edit lock is owned by another user; refusing to release it",
262
+ normalized_args=normalized_args,
263
+ recoverable=False,
264
+ details={
265
+ "lock_owner_email": requested_owner_email or None,
266
+ "lock_owner_name": requested_owner_name or None,
267
+ "current_user_email": identity.get("email"),
268
+ "current_user_name": identity.get("nick_name"),
269
+ },
270
+ suggested_next_call=None,
271
+ )
272
+ try:
273
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
274
+ except (QingflowApiError, RuntimeError) as error:
275
+ api_error = _coerce_api_error(error)
276
+ return _failed_from_api_error(
277
+ "EDIT_LOCK_RELEASE_FAILED",
278
+ api_error,
279
+ normalized_args=normalized_args,
280
+ details={"app_key": app_key},
281
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
282
+ )
283
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
284
+ try:
285
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
286
+ except (QingflowApiError, RuntimeError) as error:
287
+ api_error = _coerce_api_error(error)
288
+ return _failed_from_api_error(
289
+ "EDIT_LOCK_RELEASE_FAILED",
290
+ api_error,
291
+ normalized_args=normalized_args,
292
+ details={
293
+ "app_key": app_key,
294
+ "edit_version_no": edit_version_no,
295
+ "lock_owner_email": requested_owner_email or None,
296
+ "lock_owner_name": requested_owner_name or None,
297
+ },
298
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
299
+ )
300
+ return {
301
+ "status": "success",
302
+ "error_code": None,
303
+ "recoverable": False,
304
+ "message": "released app edit lock owned by current user",
305
+ "normalized_args": normalized_args,
306
+ "missing_fields": [],
307
+ "allowed_values": {},
308
+ "details": {
309
+ "lock_owner_email": requested_owner_email or None,
310
+ "lock_owner_name": requested_owner_name or None,
311
+ "current_user_email": identity.get("email"),
312
+ "current_user_name": identity.get("nick_name"),
313
+ "edit_version_no": edit_version_no,
314
+ },
315
+ "request_id": None,
316
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
317
+ "noop": False,
318
+ "verification": {"released": True},
319
+ "app_key": app_key,
320
+ "released": True,
321
+ }
322
+
213
323
  def app_resolve(
214
324
  self,
215
325
  *,
@@ -1347,7 +1457,20 @@ class AiBuilderFacade:
1347
1457
  if stage.get("status") != "success":
1348
1458
  failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
1349
1459
  failed["normalized_args"] = normalized_args
1350
- failed["suggested_next_call"] = failed.get("suggested_next_call") or {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1460
+ suggested_next_call = failed.get("suggested_next_call")
1461
+ if not isinstance(suggested_next_call, dict):
1462
+ suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1463
+ elif suggested_next_call.get("tool_name") == "app_flow_plan":
1464
+ arguments = suggested_next_call.get("arguments")
1465
+ if not isinstance(arguments, dict):
1466
+ arguments = {}
1467
+ arguments.setdefault("profile", profile)
1468
+ arguments.setdefault("app_key", app_key)
1469
+ arguments.setdefault("mode", mode)
1470
+ arguments.setdefault("nodes", nodes)
1471
+ arguments.setdefault("transitions", transitions)
1472
+ suggested_next_call["arguments"] = arguments
1473
+ failed["suggested_next_call"] = suggested_next_call
1351
1474
  return failed
1352
1475
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
1353
1476
  response = {
@@ -1511,6 +1634,33 @@ class AiBuilderFacade:
1511
1634
  created.append(patch.name)
1512
1635
  except (QingflowApiError, RuntimeError) as error:
1513
1636
  api_error = _coerce_api_error(error)
1637
+ if api_error.backend_code == 48104:
1638
+ try:
1639
+ if existing_key or created_key:
1640
+ target_key = created_key or existing_key or ""
1641
+ fallback_payload = _build_minimal_view_payload(
1642
+ app_key=app_key,
1643
+ schema=schema,
1644
+ patch=patch,
1645
+ ordinal=ordinal,
1646
+ )
1647
+ self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
1648
+ if existing_key:
1649
+ updated.append(patch.name)
1650
+ else:
1651
+ created.append(patch.name)
1652
+ continue
1653
+ fallback_payload = _build_minimal_view_payload(
1654
+ app_key=app_key,
1655
+ schema=schema,
1656
+ patch=patch,
1657
+ ordinal=ordinal,
1658
+ )
1659
+ self.views.view_create(profile=profile, payload=fallback_payload)
1660
+ created.append(patch.name)
1661
+ continue
1662
+ except (QingflowApiError, RuntimeError) as fallback_error:
1663
+ api_error = _coerce_api_error(fallback_error)
1514
1664
  if created_key:
1515
1665
  try:
1516
1666
  self.views.view_delete(profile=profile, viewgraph_key=created_key)
@@ -1526,6 +1676,7 @@ class AiBuilderFacade:
1526
1676
  "view_type": patch.type.value,
1527
1677
  "columns": patch.columns,
1528
1678
  "group_by": patch.group_by,
1679
+ "operation": "update" if existing_key or created_key else "create",
1529
1680
  },
1530
1681
  suggested_next_call={
1531
1682
  "tool_name": "app_views_plan",
@@ -1968,6 +2119,33 @@ class AiBuilderFacade:
1968
2119
  payload.setdefault("ws_id", session_profile.selected_ws_id)
1969
2120
  return payload
1970
2121
 
2122
+ def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
2123
+ session_profile = self.apps.sessions.get_profile(profile)
2124
+ backend_session = self.apps.sessions.get_backend_session(profile)
2125
+ current_email = str((session_profile.email if session_profile else None) or "").strip()
2126
+ current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
2127
+ if current_email or current_name or session_profile is None or backend_session is None:
2128
+ return {"email": current_email or None, "nick_name": current_name or None}
2129
+ try:
2130
+ user_info = self.apps.backend.request(
2131
+ "GET",
2132
+ BackendRequestContext(
2133
+ base_url=backend_session.base_url,
2134
+ token=backend_session.token,
2135
+ ws_id=session_profile.selected_ws_id,
2136
+ qf_version=backend_session.qf_version,
2137
+ qf_version_source=backend_session.qf_version_source,
2138
+ ),
2139
+ "/user",
2140
+ )
2141
+ except (QingflowApiError, RuntimeError):
2142
+ return {"email": current_email or None, "nick_name": current_name or None}
2143
+ if not isinstance(user_info, dict):
2144
+ return {"email": current_email or None, "nick_name": current_name or None}
2145
+ resolved_email = str(user_info.get("email") or "").strip() or None
2146
+ resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
2147
+ return {"email": resolved_email, "nick_name": resolved_name}
2148
+
1971
2149
  def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
1972
2150
  detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
1973
2151
  result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
@@ -2032,9 +2210,28 @@ def _failed_from_api_error(
2032
2210
  suggested_next_call: JSONObject | None = None,
2033
2211
  recoverable: bool = True,
2034
2212
  ) -> JSONObject:
2035
- public_message = _public_error_message(error_code, error)
2213
+ effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
2214
+ public_message = _public_error_message(effective_error_code, error)
2036
2215
  public_http_status = None if error.http_status == 404 else error.http_status
2037
2216
  merged_details = dict(details or {})
2217
+ if error.backend_code == 40074:
2218
+ owner = _extract_edit_lock_owner(error.message)
2219
+ merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
2220
+ merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
2221
+ app_key = None
2222
+ if isinstance(normalized_args, dict):
2223
+ app_key = normalized_args.get("app_key")
2224
+ if not app_key and isinstance(details, dict):
2225
+ app_key = details.get("app_key")
2226
+ if isinstance(app_key, str) and app_key.strip():
2227
+ suggested_next_call = {
2228
+ "tool_name": "app_release_edit_lock_if_mine",
2229
+ "arguments": {
2230
+ "app_key": app_key,
2231
+ "lock_owner_name": owner.get("lock_owner_name") or "",
2232
+ "lock_owner_email": owner.get("lock_owner_email") or "",
2233
+ },
2234
+ }
2038
2235
  if error.http_status is not None or error.backend_code is not None:
2039
2236
  merged_details.setdefault(
2040
2237
  "transport_error",
@@ -2045,7 +2242,7 @@ def _failed_from_api_error(
2045
2242
  },
2046
2243
  )
2047
2244
  return _failed(
2048
- error_code,
2245
+ effective_error_code,
2049
2246
  public_message,
2050
2247
  recoverable=recoverable,
2051
2248
  normalized_args=normalized_args,
@@ -2095,6 +2292,12 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
2095
2292
 
2096
2293
 
2097
2294
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
2295
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
2296
+ owner = _extract_edit_lock_owner(error.message)
2297
+ owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
2298
+ if owner_label:
2299
+ return f"app is currently locked by active editor {owner_label}"
2300
+ return "app is currently locked by another active editor session"
2098
2301
  if error.http_status != 404:
2099
2302
  return error.message
2100
2303
  mapping = {
@@ -2111,10 +2314,29 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
2111
2314
  "VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
2112
2315
  "LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
2113
2316
  "SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
2317
+ "EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
2114
2318
  }
2115
2319
  return mapping.get(error_code, "requested builder resource is unavailable in the current route")
2116
2320
 
2117
2321
 
2322
+ def _extract_edit_lock_owner(message: str) -> JSONObject:
2323
+ text = str(message or "").strip()
2324
+ if not text:
2325
+ return {"lock_owner_name": None, "lock_owner_email": None}
2326
+ patterns = [
2327
+ r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
2328
+ r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
2329
+ ]
2330
+ for pattern in patterns:
2331
+ match = re.search(pattern, text)
2332
+ if match:
2333
+ return {
2334
+ "lock_owner_name": match.groupdict().get("name", "").strip() or None,
2335
+ "lock_owner_email": match.groupdict().get("email", "").strip() or None,
2336
+ }
2337
+ return {"lock_owner_name": None, "lock_owner_email": None}
2338
+
2339
+
2118
2340
  def _coerce_positive_int(value: Any) -> int | None:
2119
2341
  if isinstance(value, bool) or value is None:
2120
2342
  return None
@@ -2851,19 +3073,17 @@ def _build_view_create_payload(
2851
3073
  payload["auth"] = default_member_auth()
2852
3074
  payload.setdefault("sortType", "defaultSort")
2853
3075
  payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
2854
- payload.setdefault("beingPinNavigate", True)
2855
- payload.setdefault("beingShowCover", False)
2856
- payload.setdefault("defaultRowHigh", "compact")
2857
- payload.setdefault("asosChartVisible", False)
2858
- payload.setdefault("viewgraphLimitType", 1)
2859
- payload.setdefault("viewgraphLimit", [])
2860
- payload.setdefault("buttonConfigDTOList", [])
2861
3076
  if patch.type.value in {"card", "board"}:
2862
3077
  payload["beingShowTitleQue"] = True
2863
3078
  payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
2864
3079
  if patch.type.value == "board":
2865
3080
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2866
- return payload
3081
+ return _hydrate_view_backend_payload(
3082
+ payload=payload,
3083
+ view_type=patch.type.value,
3084
+ visible_que_id_values=visible_que_ids,
3085
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3086
+ )
2867
3087
 
2868
3088
 
2869
3089
  def _build_form_payload_from_existing_schema(
@@ -3055,7 +3275,109 @@ def _build_view_update_payload(
3055
3275
  payload["beingShowTitleQue"] = True
3056
3276
  payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3057
3277
  payload["groupQueId"] = field_map.get(patch.group_by or "")
3058
- return payload
3278
+ return _hydrate_view_backend_payload(
3279
+ payload=payload,
3280
+ view_type=normalized_type,
3281
+ visible_que_id_values=visible_que_id_values,
3282
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3283
+ )
3284
+
3285
+
3286
+ def _build_minimal_view_payload(
3287
+ *,
3288
+ app_key: str,
3289
+ schema: dict[str, Any],
3290
+ patch: ViewUpsertPatch,
3291
+ ordinal: int,
3292
+ ) -> JSONObject:
3293
+ field_map = extract_field_map(schema)
3294
+ visible_que_id_values = [field_map[name] for name in patch.columns if name in field_map]
3295
+ payload: JSONObject = {
3296
+ "appKey": app_key,
3297
+ "viewgraphName": patch.name,
3298
+ "viewgraphType": {
3299
+ "table": "tableView",
3300
+ "card": "cardView",
3301
+ "board": "boardView",
3302
+ }[patch.type.value],
3303
+ "ordinal": ordinal,
3304
+ "viewgraphQueIds": visible_que_id_values,
3305
+ "viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
3306
+ "auth": default_member_auth(),
3307
+ }
3308
+ if patch.type.value in {"card", "board"}:
3309
+ payload["beingShowTitleQue"] = True
3310
+ payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3311
+ if patch.type.value == "board":
3312
+ payload["groupQueId"] = field_map.get(patch.group_by or "")
3313
+ return _hydrate_view_backend_payload(
3314
+ payload=payload,
3315
+ view_type=patch.type.value,
3316
+ visible_que_id_values=visible_que_id_values,
3317
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3318
+ )
3319
+
3320
+
3321
+ def _hydrate_view_backend_payload(
3322
+ *,
3323
+ payload: JSONObject,
3324
+ view_type: str,
3325
+ visible_que_id_values: list[int],
3326
+ group_que_id: int | None,
3327
+ ) -> JSONObject:
3328
+ data = deepcopy(payload)
3329
+ data.setdefault("beingPinNavigate", True)
3330
+ data.setdefault("beingNeedPass", False)
3331
+ data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
3332
+ data.setdefault("beingShowCover", False)
3333
+ data.setdefault("defaultRowHigh", "compact")
3334
+ data.setdefault("asosChartVisible", False)
3335
+ data.setdefault("viewgraphPass", "")
3336
+ data.setdefault("beingGroupColor", False)
3337
+ data.setdefault("beingShowQueTitle", True)
3338
+ data.setdefault("beingImageAdaption", False)
3339
+ data.setdefault("clippingMode", "default")
3340
+ data.setdefault("frontCoverQueId", None)
3341
+ data.setdefault("viewgraphLimitType", 1)
3342
+ data.setdefault("viewgraphLimit", [])
3343
+ data.setdefault("viewgraphLimitFormula", "")
3344
+ data.setdefault("sortType", "defaultSort")
3345
+ if not data.get("viewgraphSorts"):
3346
+ sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
3347
+ data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
3348
+ data.setdefault("beingAuditRecordVisible", True)
3349
+ data.setdefault("beingQrobotRecordVisible", False)
3350
+ data.setdefault("beingPrintStatus", False)
3351
+ data.setdefault("beingDefaultPrintTplStatus", False)
3352
+ data.setdefault("printTpls", [])
3353
+ data.setdefault("beingCommentStatus", False)
3354
+ data.setdefault("usages", [])
3355
+ data.setdefault("dataPermissionType", "CUSTOM")
3356
+ data.setdefault("dataScope", "ALL")
3357
+ data.setdefault("needPass", False)
3358
+ data.setdefault("beingWorkflowNodeFutureListVisible", True)
3359
+ data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
3360
+ data.setdefault("viewgraphGanttConfigVO", None)
3361
+ data.setdefault("viewgraphHierarchyConfigVO", None)
3362
+ data.setdefault("buttonConfigDTOList", [])
3363
+ if "buttonConfig" not in data:
3364
+ data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
3365
+ if view_type == "table":
3366
+ data["viewgraphType"] = "tableView"
3367
+ data["beingShowTitleQue"] = False
3368
+ data["titleQue"] = None
3369
+ data["groupQueId"] = None
3370
+ elif view_type == "card":
3371
+ data["viewgraphType"] = "cardView"
3372
+ data["beingShowTitleQue"] = True
3373
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3374
+ data["groupQueId"] = None
3375
+ elif view_type == "board":
3376
+ data["viewgraphType"] = "boardView"
3377
+ data["beingShowTitleQue"] = True
3378
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3379
+ data["groupQueId"] = group_que_id
3380
+ return data
3059
3381
 
3060
3382
 
3061
3383
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
@@ -3142,14 +3464,25 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
3142
3464
  backend_code=backend_code,
3143
3465
  http_status=http_status,
3144
3466
  )
3467
+ message = detail_text or "failed to apply workflow patch"
3468
+ details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
3469
+ public_http_status = http_status
3470
+ if http_status == 404:
3471
+ message = "workflow write route is unavailable for this app in the current route"
3472
+ details["transport_error"] = {
3473
+ "http_status": http_status,
3474
+ "backend_code": backend_code,
3475
+ "category": "http",
3476
+ }
3477
+ public_http_status = None
3145
3478
  return _failed(
3146
3479
  stage_error_code,
3147
- detail_text or "failed to apply workflow patch",
3148
- details={"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result},
3480
+ message,
3481
+ details=details,
3149
3482
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
3150
3483
  request_id=request_id,
3151
3484
  backend_code=backend_code,
3152
- http_status=http_status,
3485
+ http_status=public_http_status,
3153
3486
  )
3154
3487
 
3155
3488
 
@@ -22,6 +22,7 @@ def build_builder_server() -> FastMCP:
22
22
  "app_schema_plan/app_layout_plan/app_flow_plan/app_views_plan before writes when the target patch is non-trivial, "
23
23
  "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. "
24
24
  "Use package_attach_app to attach apps to packages, and app_publish_verify for explicit final publish verification. "
25
+ "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
26
  "Do not handcraft internal solution payloads or rely on build_id/stage/repair."
26
27
  ),
27
28
  )
@@ -133,6 +134,20 @@ def build_builder_server() -> FastMCP:
133
134
  ) -> dict:
134
135
  return ai_builder.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title)
135
136
 
137
+ @server.tool()
138
+ def app_release_edit_lock_if_mine(
139
+ profile: str = DEFAULT_PROFILE,
140
+ app_key: str = "",
141
+ lock_owner_email: str = "",
142
+ lock_owner_name: str = "",
143
+ ) -> dict:
144
+ return ai_builder.app_release_edit_lock_if_mine(
145
+ profile=profile,
146
+ app_key=app_key,
147
+ lock_owner_email=lock_owner_email,
148
+ lock_owner_name=lock_owner_name,
149
+ )
150
+
136
151
  @server.tool()
137
152
  def app_resolve(
138
153
  profile: str = DEFAULT_PROFILE,
@@ -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,
@@ -106,11 +111,13 @@ def _build_audit_user_infos(node) -> dict[str, Any]:
106
111
  return audit_user_infos
107
112
 
108
113
 
109
- def _prev_node_ref(node, branch_lane_ref: str | None) -> str:
114
+ def _prev_node_ref(node, branch_lane_ref: str | None, start_node_ids: set[str]) -> str:
110
115
  if branch_lane_ref:
111
116
  if node.parent_node_id and node.parent_node_id != node.branch_parent_id:
112
117
  return node.parent_node_id
113
118
  return branch_lane_ref
119
+ if node.parent_node_id in start_node_ids:
120
+ return "__applicant__"
114
121
  return node.parent_node_id or "__applicant__"
115
122
 
116
123
 
@@ -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)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
 
5
6
  from pydantic import ValidationError
6
7
 
@@ -59,6 +60,20 @@ class AiBuilderTools(ToolBase):
59
60
  ) -> JSONObject:
60
61
  return self.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title)
61
62
 
63
+ @mcp.tool()
64
+ def app_release_edit_lock_if_mine(
65
+ profile: str = DEFAULT_PROFILE,
66
+ app_key: str = "",
67
+ lock_owner_email: str = "",
68
+ lock_owner_name: str = "",
69
+ ) -> JSONObject:
70
+ return self.app_release_edit_lock_if_mine(
71
+ profile=profile,
72
+ app_key=app_key,
73
+ lock_owner_email=lock_owner_email,
74
+ lock_owner_name=lock_owner_name,
75
+ )
76
+
62
77
  @mcp.tool()
63
78
  def app_resolve(
64
79
  profile: str = DEFAULT_PROFILE,
@@ -262,12 +277,47 @@ class AiBuilderTools(ToolBase):
262
277
 
263
278
  def package_attach_app(self, *, profile: str, tag_id: int, app_key: str, app_title: str = "") -> JSONObject:
264
279
  normalized_args = {"tag_id": tag_id, "app_key": app_key, "app_title": app_title}
265
- return _safe_tool_call(
280
+ result = _safe_tool_call(
266
281
  lambda: self._facade.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title),
267
282
  error_code="PACKAGE_ATTACH_FAILED",
268
283
  normalized_args=normalized_args,
269
284
  suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, **normalized_args}},
270
285
  )
286
+ return self._retry_after_self_lock_release(
287
+ profile=profile,
288
+ result=result,
289
+ retry_call=lambda: self._facade.package_attach_app(
290
+ profile=profile,
291
+ tag_id=tag_id,
292
+ app_key=app_key,
293
+ app_title=app_title,
294
+ ),
295
+ )
296
+
297
+ def app_release_edit_lock_if_mine(
298
+ self,
299
+ *,
300
+ profile: str,
301
+ app_key: str,
302
+ lock_owner_email: str = "",
303
+ lock_owner_name: str = "",
304
+ ) -> JSONObject:
305
+ normalized_args = {
306
+ "app_key": app_key,
307
+ "lock_owner_email": lock_owner_email,
308
+ "lock_owner_name": lock_owner_name,
309
+ }
310
+ return _safe_tool_call(
311
+ lambda: self._facade.app_release_edit_lock_if_mine(
312
+ profile=profile,
313
+ app_key=app_key,
314
+ lock_owner_email=lock_owner_email,
315
+ lock_owner_name=lock_owner_name,
316
+ ),
317
+ error_code="EDIT_LOCK_RELEASE_FAILED",
318
+ normalized_args=normalized_args,
319
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
320
+ )
271
321
 
272
322
  def app_resolve(self, *, profile: str, app_key: str = "", app_name: str = "", package_tag_id: int | None = None) -> JSONObject:
273
323
  normalized_args = {"app_key": app_key, "app_name": app_name, "package_tag_id": package_tag_id}
@@ -537,7 +587,7 @@ class AiBuilderTools(ToolBase):
537
587
  "update_fields": [patch.model_dump(mode="json") for patch in parsed_update],
538
588
  "remove_fields": [patch.model_dump(mode="json") for patch in parsed_remove],
539
589
  }
540
- return _safe_tool_call(
590
+ result = _safe_tool_call(
541
591
  lambda: self._facade.app_schema_apply(
542
592
  profile=profile,
543
593
  app_key=app_key,
@@ -553,6 +603,17 @@ class AiBuilderTools(ToolBase):
553
603
  normalized_args=normalized_args,
554
604
  suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
555
605
  )
606
+ return self._retry_after_self_lock_release(profile=profile, result=result, retry_call=lambda: self._facade.app_schema_apply(
607
+ profile=profile,
608
+ app_key=app_key,
609
+ package_tag_id=package_tag_id,
610
+ app_name=effective_app_name,
611
+ create_if_missing=create_if_missing,
612
+ publish=publish,
613
+ add_fields=parsed_add,
614
+ update_fields=parsed_update,
615
+ remove_fields=parsed_remove,
616
+ ))
556
617
 
557
618
  def app_layout_apply(self, *, profile: str, app_key: str, mode: str = "merge", publish: bool = True, sections: list[JSONObject]) -> JSONObject:
558
619
  try:
@@ -593,12 +654,23 @@ class AiBuilderTools(ToolBase):
593
654
  "publish": publish,
594
655
  "sections": [section.model_dump(mode="json") for section in parsed_sections],
595
656
  }
596
- return _safe_tool_call(
657
+ result = _safe_tool_call(
597
658
  lambda: self._facade.app_layout_apply(profile=profile, app_key=app_key, mode=parsed_mode, publish=publish, sections=parsed_sections),
598
659
  error_code="LAYOUT_APPLY_FAILED",
599
660
  normalized_args=normalized_args,
600
661
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
601
662
  )
663
+ return self._retry_after_self_lock_release(
664
+ profile=profile,
665
+ result=result,
666
+ retry_call=lambda: self._facade.app_layout_apply(
667
+ profile=profile,
668
+ app_key=app_key,
669
+ mode=parsed_mode,
670
+ publish=publish,
671
+ sections=parsed_sections,
672
+ ),
673
+ )
602
674
 
603
675
  def app_flow_apply(
604
676
  self,
@@ -641,7 +713,7 @@ class AiBuilderTools(ToolBase):
641
713
  "nodes": [node.model_dump(mode="json") for node in request.nodes],
642
714
  "transitions": [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions],
643
715
  }
644
- return _safe_tool_call(
716
+ result = _safe_tool_call(
645
717
  lambda: self._facade.app_flow_apply(
646
718
  profile=profile,
647
719
  app_key=request.app_key,
@@ -654,6 +726,18 @@ class AiBuilderTools(ToolBase):
654
726
  normalized_args=normalized_args,
655
727
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
656
728
  )
729
+ return self._retry_after_self_lock_release(
730
+ profile=profile,
731
+ result=result,
732
+ retry_call=lambda: self._facade.app_flow_apply(
733
+ profile=profile,
734
+ app_key=request.app_key,
735
+ mode=request.mode,
736
+ publish=publish,
737
+ nodes=[node.model_dump(mode="json") for node in request.nodes],
738
+ transitions=[transition.model_dump(mode="json", by_alias=True) for transition in request.transitions],
739
+ ),
740
+ )
657
741
 
658
742
  def app_views_apply(
659
743
  self,
@@ -685,21 +769,98 @@ class AiBuilderTools(ToolBase):
685
769
  "upsert_views": [view.model_dump(mode="json") for view in parsed_views],
686
770
  "remove_views": list(remove_views),
687
771
  }
688
- return _safe_tool_call(
772
+ result = _safe_tool_call(
689
773
  lambda: self._facade.app_views_apply(profile=profile, app_key=app_key, publish=publish, upsert_views=parsed_views, remove_views=remove_views),
690
774
  error_code="VIEWS_APPLY_FAILED",
691
775
  normalized_args=normalized_args,
692
776
  suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **normalized_args}},
693
777
  )
778
+ return self._retry_after_self_lock_release(
779
+ profile=profile,
780
+ result=result,
781
+ retry_call=lambda: self._facade.app_views_apply(
782
+ profile=profile,
783
+ app_key=app_key,
784
+ publish=publish,
785
+ upsert_views=parsed_views,
786
+ remove_views=remove_views,
787
+ ),
788
+ )
694
789
 
695
790
  def app_publish_verify(self, *, profile: str, app_key: str, expected_package_tag_id: int | None = None) -> JSONObject:
696
791
  normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
697
- return _safe_tool_call(
792
+ result = _safe_tool_call(
698
793
  lambda: self._facade.app_publish_verify(profile=profile, app_key=app_key, expected_package_tag_id=expected_package_tag_id),
699
794
  error_code="PUBLISH_VERIFY_FAILED",
700
795
  normalized_args=normalized_args,
701
796
  suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, **normalized_args}},
702
797
  )
798
+ return self._retry_after_self_lock_release(
799
+ profile=profile,
800
+ result=result,
801
+ retry_call=lambda: self._facade.app_publish_verify(
802
+ profile=profile,
803
+ app_key=app_key,
804
+ expected_package_tag_id=expected_package_tag_id,
805
+ ),
806
+ )
807
+
808
+ def _retry_after_self_lock_release(self, *, profile: str, result: JSONObject, retry_call) -> JSONObject:
809
+ if not isinstance(result, dict) or result.get("status") != "failed" or result.get("error_code") != "APP_EDIT_LOCKED":
810
+ return result
811
+ suggested = result.get("suggested_next_call")
812
+ if not isinstance(suggested, dict) or suggested.get("tool_name") != "app_release_edit_lock_if_mine":
813
+ return result
814
+ arguments = suggested.get("arguments")
815
+ if not isinstance(arguments, dict):
816
+ return result
817
+ app_key = str(arguments.get("app_key") or "")
818
+ lock_owner_email = str(arguments.get("lock_owner_email") or "")
819
+ lock_owner_name = str(arguments.get("lock_owner_name") or "")
820
+ release_attempts: list[JSONObject] = []
821
+ retried: JSONObject = result
822
+ for _ in range(3):
823
+ release_result = self.app_release_edit_lock_if_mine(
824
+ profile=profile,
825
+ app_key=app_key,
826
+ lock_owner_email=lock_owner_email,
827
+ lock_owner_name=lock_owner_name,
828
+ )
829
+ release_attempts.append(release_result)
830
+ if not isinstance(release_result, dict) or release_result.get("status") != "success":
831
+ result.setdefault("details", {})
832
+ if isinstance(result["details"], dict):
833
+ result["details"]["edit_lock_release_result"] = release_result
834
+ result["details"]["edit_lock_release_attempts"] = release_attempts
835
+ return result
836
+ retried = retry_call()
837
+ if not (
838
+ isinstance(retried, dict)
839
+ and retried.get("status") == "failed"
840
+ and retried.get("error_code") == "APP_EDIT_LOCKED"
841
+ ):
842
+ break
843
+ time.sleep(0.2)
844
+ if (
845
+ isinstance(retried, dict)
846
+ and retried.get("status") == "failed"
847
+ and retried.get("error_code") == "APP_EDIT_LOCKED"
848
+ ):
849
+ retried = {
850
+ **retried,
851
+ "error_code": "PERSISTENT_SELF_LOCK",
852
+ "message": "app remains locked by the current user's active editor session after repeated forced release attempts",
853
+ "recoverable": True,
854
+ "suggested_next_call": None,
855
+ }
856
+ if isinstance(retried, dict):
857
+ retried.setdefault("details", {})
858
+ if isinstance(retried["details"], dict):
859
+ retried["details"]["edit_lock_release_result"] = release_attempts[-1] if release_attempts else None
860
+ retried["details"]["edit_lock_release_attempts"] = release_attempts
861
+ retried["edit_lock_released"] = bool(release_attempts)
862
+ retried["retried_after_edit_lock_release"] = True
863
+ return retried
703
864
 
704
865
 
705
866
  def _validation_failure(detail: str, *, suggested_next_call: JSONObject | None = None) -> JSONObject:
@@ -779,6 +940,8 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
779
940
 
780
941
 
781
942
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
943
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
944
+ return "app is currently locked by another active editor session"
782
945
  if error.http_status != 404:
783
946
  return error.message
784
947
  mapping = {
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from ..backend_client import BackendRequestContext
5
6
  from ..config import DEFAULT_PROFILE
6
7
  from ..errors import QingflowApiError, raise_tool_error
7
8
  from ..json_types import JSONObject, JSONValue
@@ -70,13 +71,28 @@ class WorkflowTools(ToolBase):
70
71
  def workflow_add_node(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
71
72
  self._require_app_key(app_key)
72
73
  body = self._require_dict(payload)
73
- return self._request(profile, "POST", f"/app/{app_key}/auditNodes", app_key=app_key, json_body=body)
74
+ return self._request_with_post_fallbacks(
75
+ profile=profile,
76
+ app_key=app_key,
77
+ path=f"/app/{app_key}/auditNodes",
78
+ json_body=body,
79
+ alternate_paths=[f"/app/{app_key}/auditNode"],
80
+ )
74
81
 
75
82
  def workflow_update_node(self, *, profile: str, app_key: str, audit_node_id: int, payload: JSONObject) -> JSONObject:
76
83
  self._require_app_key(app_key)
77
84
  self._require_positive("audit_node_id", audit_node_id)
78
85
  body = self._require_dict(payload)
79
- return self._request(profile, "POST", f"/app/{app_key}/auditNodes/{audit_node_id}", app_key=app_key, audit_node_id=audit_node_id, json_body=body, risk_operation="update", risk_target="workflow node configuration")
86
+ return self._request_with_post_fallbacks(
87
+ profile=profile,
88
+ app_key=app_key,
89
+ path=f"/app/{app_key}/auditNodes/{audit_node_id}",
90
+ json_body=body,
91
+ alternate_paths=[f"/app/{app_key}/auditNode/{audit_node_id}"],
92
+ risk_operation="update",
93
+ risk_target="workflow node configuration",
94
+ audit_node_id=audit_node_id,
95
+ )
80
96
 
81
97
  def workflow_delete_node(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
82
98
  self._require_app_key(app_key)
@@ -110,12 +126,26 @@ class WorkflowTools(ToolBase):
110
126
  def workflow_update_global_settings(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
111
127
  self._require_app_key(app_key)
112
128
  body = self._require_dict(payload)
113
- return self._request(profile, "POST", f"/app/{app_key}/workflow/global/setting", app_key=app_key, json_body=body, risk_operation="update", risk_target="workflow global settings")
129
+ return self._request_with_post_fallbacks(
130
+ profile=profile,
131
+ app_key=app_key,
132
+ path=f"/app/{app_key}/workflow/global/setting",
133
+ json_body=body,
134
+ alternate_paths=[],
135
+ risk_operation="update",
136
+ risk_target="workflow global settings",
137
+ )
114
138
 
115
139
  def workflow_publish(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
116
140
  self._require_app_key(app_key)
117
141
  body = self._require_dict(payload)
118
- return self._request(profile, "POST", f"/app/{app_key}/publish", app_key=app_key, json_body=body)
142
+ return self._request_with_post_fallbacks(
143
+ profile=profile,
144
+ app_key=app_key,
145
+ path=f"/app/{app_key}/publish",
146
+ json_body=body,
147
+ alternate_paths=[],
148
+ )
119
149
 
120
150
  def workflow_get_future_nodes(self, *, profile: str, app_key: str, apply_id: int) -> JSONObject:
121
151
  self._require_app_key(app_key)
@@ -229,6 +259,50 @@ class WorkflowTools(ToolBase):
229
259
 
230
260
  return self._run(profile, runner)
231
261
 
262
+ def _request_with_post_fallbacks(
263
+ self,
264
+ *,
265
+ profile: str,
266
+ app_key: str,
267
+ path: str,
268
+ json_body: JSONObject,
269
+ alternate_paths: list[str],
270
+ risk_operation: str | None = None,
271
+ risk_target: str | None = None,
272
+ **extra: JSONValue,
273
+ ) -> JSONObject:
274
+ def runner(session_profile, context):
275
+ attempted_contexts = [context]
276
+ if context.qf_version is not None:
277
+ attempted_contexts.append(
278
+ BackendRequestContext(
279
+ base_url=context.base_url,
280
+ token=context.token,
281
+ ws_id=context.ws_id,
282
+ qf_version=None,
283
+ qf_version_source="workflow_retry_without_qf_version",
284
+ )
285
+ )
286
+ paths = [path, *alternate_paths]
287
+ last_error: QingflowApiError | None = None
288
+ for call_context in attempted_contexts:
289
+ for candidate_path in paths:
290
+ try:
291
+ result = self.backend.request("POST", call_context, candidate_path, json_body=json_body)
292
+ response: JSONObject = {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
293
+ response.update(extra)
294
+ if risk_operation and risk_target:
295
+ return self._attach_human_review_notice(response, operation=risk_operation, target=risk_target)
296
+ return response
297
+ except QingflowApiError as error:
298
+ last_error = error
299
+ if error.http_status != 404:
300
+ raise
301
+ assert last_error is not None
302
+ raise last_error
303
+
304
+ return self._run(profile, runner)
305
+
232
306
  def _require_app_key(self, app_key: str) -> None:
233
307
  if not app_key:
234
308
  raise_tool_error(QingflowApiError.config_error("app_key is required"))