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

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.7
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.9
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.7 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.9 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.7",
3
+ "version": "0.2.0-beta.9",
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.0b7"
7
+ version = "0.2.0b9"
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.0b7"
5
+ __version__ = "0.2.0b9"
@@ -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())
@@ -233,8 +233,9 @@ class AiBuilderFacade:
233
233
  recoverable=False,
234
234
  suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
235
235
  )
236
- current_email = str(session_profile.email or "").strip().lower()
237
- current_name = str(session_profile.nick_name or "").strip()
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()
238
239
  requested_owner_email = str(lock_owner_email or "").strip().lower()
239
240
  requested_owner_name = str(lock_owner_name or "").strip()
240
241
  if not requested_owner_email and not requested_owner_name:
@@ -244,8 +245,8 @@ class AiBuilderFacade:
244
245
  normalized_args=normalized_args,
245
246
  recoverable=False,
246
247
  details={
247
- "current_user_email": session_profile.email,
248
- "current_user_name": session_profile.nick_name,
248
+ "current_user_email": identity.get("email"),
249
+ "current_user_name": identity.get("nick_name"),
249
250
  },
250
251
  suggested_next_call=None,
251
252
  )
@@ -263,8 +264,8 @@ class AiBuilderFacade:
263
264
  details={
264
265
  "lock_owner_email": requested_owner_email or None,
265
266
  "lock_owner_name": requested_owner_name or None,
266
- "current_user_email": session_profile.email,
267
- "current_user_name": session_profile.nick_name,
267
+ "current_user_email": identity.get("email"),
268
+ "current_user_name": identity.get("nick_name"),
268
269
  },
269
270
  suggested_next_call=None,
270
271
  )
@@ -307,8 +308,8 @@ class AiBuilderFacade:
307
308
  "details": {
308
309
  "lock_owner_email": requested_owner_email or None,
309
310
  "lock_owner_name": requested_owner_name or None,
310
- "current_user_email": session_profile.email,
311
- "current_user_name": session_profile.nick_name,
311
+ "current_user_email": identity.get("email"),
312
+ "current_user_name": identity.get("nick_name"),
312
313
  "edit_version_no": edit_version_no,
313
314
  },
314
315
  "request_id": None,
@@ -1057,6 +1058,11 @@ class AiBuilderFacade:
1057
1058
  fields=current_fields,
1058
1059
  layout=layout,
1059
1060
  )
1061
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1062
+ profile=profile,
1063
+ app_key=target.app_key,
1064
+ current_schema=schema_result,
1065
+ )
1060
1066
  try:
1061
1067
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
1062
1068
  except (QingflowApiError, RuntimeError) as error:
@@ -1272,6 +1278,11 @@ class AiBuilderFacade:
1272
1278
  current_schema=schema_result,
1273
1279
  layout=target_layout,
1274
1280
  )
1281
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1282
+ profile=profile,
1283
+ app_key=app_key,
1284
+ current_schema=schema_result,
1285
+ )
1275
1286
  applied_layout = target_layout
1276
1287
  fallback_applied = None
1277
1288
  try:
@@ -1284,6 +1295,11 @@ class AiBuilderFacade:
1284
1295
  current_schema=schema_result,
1285
1296
  layout=flattened_layout,
1286
1297
  )
1298
+ fallback_payload["editVersionNo"] = self._resolve_form_edit_version(
1299
+ profile=profile,
1300
+ app_key=app_key,
1301
+ current_schema=schema_result,
1302
+ )
1287
1303
  try:
1288
1304
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
1289
1305
  applied_layout = flattened_layout
@@ -1874,6 +1890,13 @@ class AiBuilderFacade:
1874
1890
  "published": True,
1875
1891
  }
1876
1892
 
1893
+ def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
1894
+ try:
1895
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
1896
+ except (QingflowApiError, RuntimeError):
1897
+ version_result = {}
1898
+ return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
1899
+
1877
1900
  def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
1878
1901
  response["publish_requested"] = publish
1879
1902
  if not publish:
@@ -2118,6 +2141,33 @@ class AiBuilderFacade:
2118
2141
  payload.setdefault("ws_id", session_profile.selected_ws_id)
2119
2142
  return payload
2120
2143
 
2144
+ def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
2145
+ session_profile = self.apps.sessions.get_profile(profile)
2146
+ backend_session = self.apps.sessions.get_backend_session(profile)
2147
+ current_email = str((session_profile.email if session_profile else None) or "").strip()
2148
+ current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
2149
+ if current_email or current_name or session_profile is None or backend_session is None:
2150
+ return {"email": current_email or None, "nick_name": current_name or None}
2151
+ try:
2152
+ user_info = self.apps.backend.request(
2153
+ "GET",
2154
+ BackendRequestContext(
2155
+ base_url=backend_session.base_url,
2156
+ token=backend_session.token,
2157
+ ws_id=session_profile.selected_ws_id,
2158
+ qf_version=backend_session.qf_version,
2159
+ qf_version_source=backend_session.qf_version_source,
2160
+ ),
2161
+ "/user",
2162
+ )
2163
+ except (QingflowApiError, RuntimeError):
2164
+ return {"email": current_email or None, "nick_name": current_name or None}
2165
+ if not isinstance(user_info, dict):
2166
+ return {"email": current_email or None, "nick_name": current_name or None}
2167
+ resolved_email = str(user_info.get("email") or "").strip() or None
2168
+ resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
2169
+ return {"email": resolved_email, "nick_name": resolved_name}
2170
+
2121
2171
  def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
2122
2172
  detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
2123
2173
  result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
@@ -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
 
@@ -276,12 +277,22 @@ class AiBuilderTools(ToolBase):
276
277
 
277
278
  def package_attach_app(self, *, profile: str, tag_id: int, app_key: str, app_title: str = "") -> JSONObject:
278
279
  normalized_args = {"tag_id": tag_id, "app_key": app_key, "app_title": app_title}
279
- return _safe_tool_call(
280
+ result = _safe_tool_call(
280
281
  lambda: self._facade.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title),
281
282
  error_code="PACKAGE_ATTACH_FAILED",
282
283
  normalized_args=normalized_args,
283
284
  suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, **normalized_args}},
284
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
+ )
285
296
 
286
297
  def app_release_edit_lock_if_mine(
287
298
  self,
@@ -576,7 +587,7 @@ class AiBuilderTools(ToolBase):
576
587
  "update_fields": [patch.model_dump(mode="json") for patch in parsed_update],
577
588
  "remove_fields": [patch.model_dump(mode="json") for patch in parsed_remove],
578
589
  }
579
- return _safe_tool_call(
590
+ result = _safe_tool_call(
580
591
  lambda: self._facade.app_schema_apply(
581
592
  profile=profile,
582
593
  app_key=app_key,
@@ -592,6 +603,17 @@ class AiBuilderTools(ToolBase):
592
603
  normalized_args=normalized_args,
593
604
  suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
594
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
+ ))
595
617
 
596
618
  def app_layout_apply(self, *, profile: str, app_key: str, mode: str = "merge", publish: bool = True, sections: list[JSONObject]) -> JSONObject:
597
619
  try:
@@ -632,12 +654,23 @@ class AiBuilderTools(ToolBase):
632
654
  "publish": publish,
633
655
  "sections": [section.model_dump(mode="json") for section in parsed_sections],
634
656
  }
635
- return _safe_tool_call(
657
+ result = _safe_tool_call(
636
658
  lambda: self._facade.app_layout_apply(profile=profile, app_key=app_key, mode=parsed_mode, publish=publish, sections=parsed_sections),
637
659
  error_code="LAYOUT_APPLY_FAILED",
638
660
  normalized_args=normalized_args,
639
661
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
640
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
+ )
641
674
 
642
675
  def app_flow_apply(
643
676
  self,
@@ -680,7 +713,7 @@ class AiBuilderTools(ToolBase):
680
713
  "nodes": [node.model_dump(mode="json") for node in request.nodes],
681
714
  "transitions": [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions],
682
715
  }
683
- return _safe_tool_call(
716
+ result = _safe_tool_call(
684
717
  lambda: self._facade.app_flow_apply(
685
718
  profile=profile,
686
719
  app_key=request.app_key,
@@ -693,6 +726,18 @@ class AiBuilderTools(ToolBase):
693
726
  normalized_args=normalized_args,
694
727
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
695
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
+ )
696
741
 
697
742
  def app_views_apply(
698
743
  self,
@@ -724,21 +769,98 @@ class AiBuilderTools(ToolBase):
724
769
  "upsert_views": [view.model_dump(mode="json") for view in parsed_views],
725
770
  "remove_views": list(remove_views),
726
771
  }
727
- return _safe_tool_call(
772
+ result = _safe_tool_call(
728
773
  lambda: self._facade.app_views_apply(profile=profile, app_key=app_key, publish=publish, upsert_views=parsed_views, remove_views=remove_views),
729
774
  error_code="VIEWS_APPLY_FAILED",
730
775
  normalized_args=normalized_args,
731
776
  suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **normalized_args}},
732
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
+ )
733
789
 
734
790
  def app_publish_verify(self, *, profile: str, app_key: str, expected_package_tag_id: int | None = None) -> JSONObject:
735
791
  normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
736
- return _safe_tool_call(
792
+ result = _safe_tool_call(
737
793
  lambda: self._facade.app_publish_verify(profile=profile, app_key=app_key, expected_package_tag_id=expected_package_tag_id),
738
794
  error_code="PUBLISH_VERIFY_FAILED",
739
795
  normalized_args=normalized_args,
740
796
  suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, **normalized_args}},
741
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
742
864
 
743
865
 
744
866
  def _validation_failure(detail: str, *, suggested_next_call: JSONObject | None = None) -> JSONObject: