@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +4 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +320 -15
- package/src/qingflow_mcp/server_app_builder.py +15 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +41 -0
- 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-builder-mcp@0.2.0-beta.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3148
|
-
details=
|
|
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=
|
|
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.
|
|
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"))
|