@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +2 -0
- package/src/qingflow_mcp/builder_facade/service.py +348 -15
- package/src/qingflow_mcp/server_app_builder.py +15 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +9 -2
- package/src/qingflow_mcp/solution/executor.py +34 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +169 -6
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
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
|
+
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3148
|
-
details=
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"))
|