@josephyan/qingflow-app-builder-mcp 0.2.0-beta.6 → 0.2.0-beta.7

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-builder-mcp@0.2.0-beta.6
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.7
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.6 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.7 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.7",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution 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.0b7"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ Pick the smallest tool layer that can finish the task.
21
21
  - File upload: `file_upload_local`
22
22
  - Resource resolve/read: `package_list`, `package_resolve`, `app_resolve`, `app_read_summary`, `app_read_fields`, `app_read_layout_summary`, `app_read_views_summary`, `app_read_flow_summary`
23
23
  - Resource plan: `app_schema_plan`, `app_layout_plan`, `app_flow_plan`, `app_views_plan`
24
- - Resource patch: `app_schema_apply`, `app_layout_apply`, `app_flow_apply`, `app_views_apply`, `package_attach_app`
24
+ - Resource patch: `app_schema_apply`, `app_layout_apply`, `app_flow_apply`, `app_views_apply`, `package_attach_app`, `app_release_edit_lock_if_mine`
25
25
  - Publish and verify: `app_publish_verify`
26
26
 
27
27
  Note:
@@ -55,6 +55,7 @@ For builder work:
55
55
  8. Use `app_flow_apply` after schema exists. It publishes by default.
56
56
  9. Use `app_views_apply` when the user wants explicit list/card/board views. It publishes by default.
57
57
  10. Use `app_publish_verify` only when the user explicitly wants final publish/live verification or you need an explicit verification pass.
58
+ 11. If a write fails with `APP_EDIT_LOCKED`, stop normal writes. Only use `app_release_edit_lock_if_mine` when the failed result shows the lock owner is the current logged-in user.
58
59
 
59
60
  In `prod`, keep `plan` and `apply` as separate phases unless the user explicitly asks for a direct live execution.
60
61
 
@@ -74,6 +75,7 @@ For additive work on existing systems:
74
75
  - `package_attach_app` is the source of truth for package ownership; do not assume app creation or publish implicitly attaches the app.
75
76
  - `relation` and `subtable` must be explicit; do not infer them from vague natural language.
76
77
  - In `prod`, prefer explicit patch tools and avoid any speculative create flow.
78
+ - Never try to bypass collaborative edit locks. `app_release_edit_lock_if_mine` is only for the case where the lock owner is the current authenticated user.
77
79
 
78
80
  ## Response Interpretation
79
81
 
@@ -101,6 +103,7 @@ For additive work on existing systems:
101
103
  - Replace workflow: `app_flow_apply`
102
104
  - Upsert/remove views: `app_views_apply`
103
105
  - Attach one app to a package: `package_attach_app`
106
+ - Release your own stale edit lock: `app_release_edit_lock_if_mine`
104
107
  - Publish and verify: `app_publish_verify` when you need a separate verification pass beyond the default auto-publish behavior in apply tools
105
108
 
106
109
  Detailed playbooks:
@@ -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.0b7"
@@ -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,114 @@ 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
+ current_email = str(session_profile.email or "").strip().lower()
237
+ current_name = str(session_profile.nick_name or "").strip()
238
+ requested_owner_email = str(lock_owner_email or "").strip().lower()
239
+ requested_owner_name = str(lock_owner_name or "").strip()
240
+ if not requested_owner_email and not requested_owner_name:
241
+ return _failed(
242
+ "EDIT_LOCK_OWNER_UNKNOWN",
243
+ "lock owner could not be verified; refuse to release edit lock blindly",
244
+ normalized_args=normalized_args,
245
+ recoverable=False,
246
+ details={
247
+ "current_user_email": session_profile.email,
248
+ "current_user_name": session_profile.nick_name,
249
+ },
250
+ suggested_next_call=None,
251
+ )
252
+ owner_matches = True
253
+ if requested_owner_email:
254
+ owner_matches = bool(current_email) and current_email == requested_owner_email
255
+ elif requested_owner_name:
256
+ owner_matches = bool(current_name) and current_name == requested_owner_name
257
+ if not owner_matches:
258
+ return _failed(
259
+ "EDIT_LOCK_HELD_BY_OTHER_USER",
260
+ "edit lock is owned by another user; refusing to release it",
261
+ normalized_args=normalized_args,
262
+ recoverable=False,
263
+ details={
264
+ "lock_owner_email": requested_owner_email or None,
265
+ "lock_owner_name": requested_owner_name or None,
266
+ "current_user_email": session_profile.email,
267
+ "current_user_name": session_profile.nick_name,
268
+ },
269
+ suggested_next_call=None,
270
+ )
271
+ try:
272
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
273
+ except (QingflowApiError, RuntimeError) as error:
274
+ api_error = _coerce_api_error(error)
275
+ return _failed_from_api_error(
276
+ "EDIT_LOCK_RELEASE_FAILED",
277
+ api_error,
278
+ normalized_args=normalized_args,
279
+ details={"app_key": app_key},
280
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
281
+ )
282
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
283
+ try:
284
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
285
+ except (QingflowApiError, RuntimeError) as error:
286
+ api_error = _coerce_api_error(error)
287
+ return _failed_from_api_error(
288
+ "EDIT_LOCK_RELEASE_FAILED",
289
+ api_error,
290
+ normalized_args=normalized_args,
291
+ details={
292
+ "app_key": app_key,
293
+ "edit_version_no": edit_version_no,
294
+ "lock_owner_email": requested_owner_email or None,
295
+ "lock_owner_name": requested_owner_name or None,
296
+ },
297
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
298
+ )
299
+ return {
300
+ "status": "success",
301
+ "error_code": None,
302
+ "recoverable": False,
303
+ "message": "released app edit lock owned by current user",
304
+ "normalized_args": normalized_args,
305
+ "missing_fields": [],
306
+ "allowed_values": {},
307
+ "details": {
308
+ "lock_owner_email": requested_owner_email or None,
309
+ "lock_owner_name": requested_owner_name or None,
310
+ "current_user_email": session_profile.email,
311
+ "current_user_name": session_profile.nick_name,
312
+ "edit_version_no": edit_version_no,
313
+ },
314
+ "request_id": None,
315
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
316
+ "noop": False,
317
+ "verification": {"released": True},
318
+ "app_key": app_key,
319
+ "released": True,
320
+ }
321
+
213
322
  def app_resolve(
214
323
  self,
215
324
  *,
@@ -1347,7 +1456,20 @@ class AiBuilderFacade:
1347
1456
  if stage.get("status") != "success":
1348
1457
  failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
1349
1458
  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}}
1459
+ suggested_next_call = failed.get("suggested_next_call")
1460
+ if not isinstance(suggested_next_call, dict):
1461
+ suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1462
+ elif suggested_next_call.get("tool_name") == "app_flow_plan":
1463
+ arguments = suggested_next_call.get("arguments")
1464
+ if not isinstance(arguments, dict):
1465
+ arguments = {}
1466
+ arguments.setdefault("profile", profile)
1467
+ arguments.setdefault("app_key", app_key)
1468
+ arguments.setdefault("mode", mode)
1469
+ arguments.setdefault("nodes", nodes)
1470
+ arguments.setdefault("transitions", transitions)
1471
+ suggested_next_call["arguments"] = arguments
1472
+ failed["suggested_next_call"] = suggested_next_call
1351
1473
  return failed
1352
1474
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
1353
1475
  response = {
@@ -1511,6 +1633,33 @@ class AiBuilderFacade:
1511
1633
  created.append(patch.name)
1512
1634
  except (QingflowApiError, RuntimeError) as error:
1513
1635
  api_error = _coerce_api_error(error)
1636
+ if api_error.backend_code == 48104:
1637
+ try:
1638
+ if existing_key or created_key:
1639
+ target_key = created_key or existing_key or ""
1640
+ fallback_payload = _build_minimal_view_payload(
1641
+ app_key=app_key,
1642
+ schema=schema,
1643
+ patch=patch,
1644
+ ordinal=ordinal,
1645
+ )
1646
+ self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
1647
+ if existing_key:
1648
+ updated.append(patch.name)
1649
+ else:
1650
+ created.append(patch.name)
1651
+ continue
1652
+ fallback_payload = _build_minimal_view_payload(
1653
+ app_key=app_key,
1654
+ schema=schema,
1655
+ patch=patch,
1656
+ ordinal=ordinal,
1657
+ )
1658
+ self.views.view_create(profile=profile, payload=fallback_payload)
1659
+ created.append(patch.name)
1660
+ continue
1661
+ except (QingflowApiError, RuntimeError) as fallback_error:
1662
+ api_error = _coerce_api_error(fallback_error)
1514
1663
  if created_key:
1515
1664
  try:
1516
1665
  self.views.view_delete(profile=profile, viewgraph_key=created_key)
@@ -1526,6 +1675,7 @@ class AiBuilderFacade:
1526
1675
  "view_type": patch.type.value,
1527
1676
  "columns": patch.columns,
1528
1677
  "group_by": patch.group_by,
1678
+ "operation": "update" if existing_key or created_key else "create",
1529
1679
  },
1530
1680
  suggested_next_call={
1531
1681
  "tool_name": "app_views_plan",
@@ -2032,9 +2182,28 @@ def _failed_from_api_error(
2032
2182
  suggested_next_call: JSONObject | None = None,
2033
2183
  recoverable: bool = True,
2034
2184
  ) -> JSONObject:
2035
- public_message = _public_error_message(error_code, error)
2185
+ effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
2186
+ public_message = _public_error_message(effective_error_code, error)
2036
2187
  public_http_status = None if error.http_status == 404 else error.http_status
2037
2188
  merged_details = dict(details or {})
2189
+ if error.backend_code == 40074:
2190
+ owner = _extract_edit_lock_owner(error.message)
2191
+ merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
2192
+ merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
2193
+ app_key = None
2194
+ if isinstance(normalized_args, dict):
2195
+ app_key = normalized_args.get("app_key")
2196
+ if not app_key and isinstance(details, dict):
2197
+ app_key = details.get("app_key")
2198
+ if isinstance(app_key, str) and app_key.strip():
2199
+ suggested_next_call = {
2200
+ "tool_name": "app_release_edit_lock_if_mine",
2201
+ "arguments": {
2202
+ "app_key": app_key,
2203
+ "lock_owner_name": owner.get("lock_owner_name") or "",
2204
+ "lock_owner_email": owner.get("lock_owner_email") or "",
2205
+ },
2206
+ }
2038
2207
  if error.http_status is not None or error.backend_code is not None:
2039
2208
  merged_details.setdefault(
2040
2209
  "transport_error",
@@ -2045,7 +2214,7 @@ def _failed_from_api_error(
2045
2214
  },
2046
2215
  )
2047
2216
  return _failed(
2048
- error_code,
2217
+ effective_error_code,
2049
2218
  public_message,
2050
2219
  recoverable=recoverable,
2051
2220
  normalized_args=normalized_args,
@@ -2095,6 +2264,12 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
2095
2264
 
2096
2265
 
2097
2266
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
2267
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
2268
+ owner = _extract_edit_lock_owner(error.message)
2269
+ owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
2270
+ if owner_label:
2271
+ return f"app is currently locked by active editor {owner_label}"
2272
+ return "app is currently locked by another active editor session"
2098
2273
  if error.http_status != 404:
2099
2274
  return error.message
2100
2275
  mapping = {
@@ -2111,10 +2286,29 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
2111
2286
  "VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
2112
2287
  "LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
2113
2288
  "SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
2289
+ "EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
2114
2290
  }
2115
2291
  return mapping.get(error_code, "requested builder resource is unavailable in the current route")
2116
2292
 
2117
2293
 
2294
+ def _extract_edit_lock_owner(message: str) -> JSONObject:
2295
+ text = str(message or "").strip()
2296
+ if not text:
2297
+ return {"lock_owner_name": None, "lock_owner_email": None}
2298
+ patterns = [
2299
+ r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
2300
+ r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
2301
+ ]
2302
+ for pattern in patterns:
2303
+ match = re.search(pattern, text)
2304
+ if match:
2305
+ return {
2306
+ "lock_owner_name": match.groupdict().get("name", "").strip() or None,
2307
+ "lock_owner_email": match.groupdict().get("email", "").strip() or None,
2308
+ }
2309
+ return {"lock_owner_name": None, "lock_owner_email": None}
2310
+
2311
+
2118
2312
  def _coerce_positive_int(value: Any) -> int | None:
2119
2313
  if isinstance(value, bool) or value is None:
2120
2314
  return None
@@ -2851,19 +3045,17 @@ def _build_view_create_payload(
2851
3045
  payload["auth"] = default_member_auth()
2852
3046
  payload.setdefault("sortType", "defaultSort")
2853
3047
  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
3048
  if patch.type.value in {"card", "board"}:
2862
3049
  payload["beingShowTitleQue"] = True
2863
3050
  payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
2864
3051
  if patch.type.value == "board":
2865
3052
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2866
- return payload
3053
+ return _hydrate_view_backend_payload(
3054
+ payload=payload,
3055
+ view_type=patch.type.value,
3056
+ visible_que_id_values=visible_que_ids,
3057
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3058
+ )
2867
3059
 
2868
3060
 
2869
3061
  def _build_form_payload_from_existing_schema(
@@ -3055,7 +3247,109 @@ def _build_view_update_payload(
3055
3247
  payload["beingShowTitleQue"] = True
3056
3248
  payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3057
3249
  payload["groupQueId"] = field_map.get(patch.group_by or "")
3058
- return payload
3250
+ return _hydrate_view_backend_payload(
3251
+ payload=payload,
3252
+ view_type=normalized_type,
3253
+ visible_que_id_values=visible_que_id_values,
3254
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3255
+ )
3256
+
3257
+
3258
+ def _build_minimal_view_payload(
3259
+ *,
3260
+ app_key: str,
3261
+ schema: dict[str, Any],
3262
+ patch: ViewUpsertPatch,
3263
+ ordinal: int,
3264
+ ) -> JSONObject:
3265
+ field_map = extract_field_map(schema)
3266
+ visible_que_id_values = [field_map[name] for name in patch.columns if name in field_map]
3267
+ payload: JSONObject = {
3268
+ "appKey": app_key,
3269
+ "viewgraphName": patch.name,
3270
+ "viewgraphType": {
3271
+ "table": "tableView",
3272
+ "card": "cardView",
3273
+ "board": "boardView",
3274
+ }[patch.type.value],
3275
+ "ordinal": ordinal,
3276
+ "viewgraphQueIds": visible_que_id_values,
3277
+ "viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
3278
+ "auth": default_member_auth(),
3279
+ }
3280
+ if patch.type.value in {"card", "board"}:
3281
+ payload["beingShowTitleQue"] = True
3282
+ payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3283
+ if patch.type.value == "board":
3284
+ payload["groupQueId"] = field_map.get(patch.group_by or "")
3285
+ return _hydrate_view_backend_payload(
3286
+ payload=payload,
3287
+ view_type=patch.type.value,
3288
+ visible_que_id_values=visible_que_id_values,
3289
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3290
+ )
3291
+
3292
+
3293
+ def _hydrate_view_backend_payload(
3294
+ *,
3295
+ payload: JSONObject,
3296
+ view_type: str,
3297
+ visible_que_id_values: list[int],
3298
+ group_que_id: int | None,
3299
+ ) -> JSONObject:
3300
+ data = deepcopy(payload)
3301
+ data.setdefault("beingPinNavigate", True)
3302
+ data.setdefault("beingNeedPass", False)
3303
+ data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
3304
+ data.setdefault("beingShowCover", False)
3305
+ data.setdefault("defaultRowHigh", "compact")
3306
+ data.setdefault("asosChartVisible", False)
3307
+ data.setdefault("viewgraphPass", "")
3308
+ data.setdefault("beingGroupColor", False)
3309
+ data.setdefault("beingShowQueTitle", True)
3310
+ data.setdefault("beingImageAdaption", False)
3311
+ data.setdefault("clippingMode", "default")
3312
+ data.setdefault("frontCoverQueId", None)
3313
+ data.setdefault("viewgraphLimitType", 1)
3314
+ data.setdefault("viewgraphLimit", [])
3315
+ data.setdefault("viewgraphLimitFormula", "")
3316
+ data.setdefault("sortType", "defaultSort")
3317
+ if not data.get("viewgraphSorts"):
3318
+ sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
3319
+ data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
3320
+ data.setdefault("beingAuditRecordVisible", True)
3321
+ data.setdefault("beingQrobotRecordVisible", False)
3322
+ data.setdefault("beingPrintStatus", False)
3323
+ data.setdefault("beingDefaultPrintTplStatus", False)
3324
+ data.setdefault("printTpls", [])
3325
+ data.setdefault("beingCommentStatus", False)
3326
+ data.setdefault("usages", [])
3327
+ data.setdefault("dataPermissionType", "CUSTOM")
3328
+ data.setdefault("dataScope", "ALL")
3329
+ data.setdefault("needPass", False)
3330
+ data.setdefault("beingWorkflowNodeFutureListVisible", True)
3331
+ data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
3332
+ data.setdefault("viewgraphGanttConfigVO", None)
3333
+ data.setdefault("viewgraphHierarchyConfigVO", None)
3334
+ data.setdefault("buttonConfigDTOList", [])
3335
+ if "buttonConfig" not in data:
3336
+ data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
3337
+ if view_type == "table":
3338
+ data["viewgraphType"] = "tableView"
3339
+ data["beingShowTitleQue"] = False
3340
+ data["titleQue"] = None
3341
+ data["groupQueId"] = None
3342
+ elif view_type == "card":
3343
+ data["viewgraphType"] = "cardView"
3344
+ data["beingShowTitleQue"] = True
3345
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3346
+ data["groupQueId"] = None
3347
+ elif view_type == "board":
3348
+ data["viewgraphType"] = "boardView"
3349
+ data["beingShowTitleQue"] = True
3350
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3351
+ data["groupQueId"] = group_que_id
3352
+ return data
3059
3353
 
3060
3354
 
3061
3355
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
@@ -3142,14 +3436,25 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
3142
3436
  backend_code=backend_code,
3143
3437
  http_status=http_status,
3144
3438
  )
3439
+ message = detail_text or "failed to apply workflow patch"
3440
+ details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
3441
+ public_http_status = http_status
3442
+ if http_status == 404:
3443
+ message = "workflow write route is unavailable for this app in the current route"
3444
+ details["transport_error"] = {
3445
+ "http_status": http_status,
3446
+ "backend_code": backend_code,
3447
+ "category": "http",
3448
+ }
3449
+ public_http_status = None
3145
3450
  return _failed(
3146
3451
  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},
3452
+ message,
3453
+ details=details,
3149
3454
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
3150
3455
  request_id=request_id,
3151
3456
  backend_code=backend_code,
3152
- http_status=http_status,
3457
+ http_status=public_http_status,
3153
3458
  )
3154
3459
 
3155
3460
 
@@ -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,
@@ -59,6 +59,20 @@ class AiBuilderTools(ToolBase):
59
59
  ) -> JSONObject:
60
60
  return self.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title)
61
61
 
62
+ @mcp.tool()
63
+ def app_release_edit_lock_if_mine(
64
+ profile: str = DEFAULT_PROFILE,
65
+ app_key: str = "",
66
+ lock_owner_email: str = "",
67
+ lock_owner_name: str = "",
68
+ ) -> JSONObject:
69
+ return self.app_release_edit_lock_if_mine(
70
+ profile=profile,
71
+ app_key=app_key,
72
+ lock_owner_email=lock_owner_email,
73
+ lock_owner_name=lock_owner_name,
74
+ )
75
+
62
76
  @mcp.tool()
63
77
  def app_resolve(
64
78
  profile: str = DEFAULT_PROFILE,
@@ -269,6 +283,31 @@ class AiBuilderTools(ToolBase):
269
283
  suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, **normalized_args}},
270
284
  )
271
285
 
286
+ def app_release_edit_lock_if_mine(
287
+ self,
288
+ *,
289
+ profile: str,
290
+ app_key: str,
291
+ lock_owner_email: str = "",
292
+ lock_owner_name: str = "",
293
+ ) -> JSONObject:
294
+ normalized_args = {
295
+ "app_key": app_key,
296
+ "lock_owner_email": lock_owner_email,
297
+ "lock_owner_name": lock_owner_name,
298
+ }
299
+ return _safe_tool_call(
300
+ lambda: self._facade.app_release_edit_lock_if_mine(
301
+ profile=profile,
302
+ app_key=app_key,
303
+ lock_owner_email=lock_owner_email,
304
+ lock_owner_name=lock_owner_name,
305
+ ),
306
+ error_code="EDIT_LOCK_RELEASE_FAILED",
307
+ normalized_args=normalized_args,
308
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
309
+ )
310
+
272
311
  def app_resolve(self, *, profile: str, app_key: str = "", app_name: str = "", package_tag_id: int | None = None) -> JSONObject:
273
312
  normalized_args = {"app_key": app_key, "app_name": app_name, "package_tag_id": package_tag_id}
274
313
  return _safe_tool_call(
@@ -779,6 +818,8 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
779
818
 
780
819
 
781
820
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
821
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
822
+ return "app is currently locked by another active editor session"
782
823
  if error.http_status != 404:
783
824
  return error.message
784
825
  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"))