@josephyan/qingflow-app-user-mcp 0.2.0-beta.1 → 0.2.0-beta.11
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 +11 -2
- package/npm/lib/runtime.mjs +37 -0
- package/npm/scripts/postinstall.mjs +5 -1
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +158 -0
- package/skills/qingflow-app-user/agents/openai.yaml +4 -0
- package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
- package/skills/qingflow-app-user/references/environments.md +63 -0
- package/skills/qingflow-app-user/references/record-patterns.md +69 -0
- package/skills/qingflow-app-user/references/workflow-usage.md +24 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +242 -0
- package/src/qingflow_mcp/builder_facade/service.py +2055 -195
- package/src/qingflow_mcp/server_app_builder.py +82 -4
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
- package/src/qingflow_mcp/solution/executor.py +34 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1001 -30
- package/src/qingflow_mcp/tools/app_tools.py +40 -2
- package/src/qingflow_mcp/tools/auth_tools.py +2 -1
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
- package/src/qingflow_mcp/tools/workspace_tools.py +6 -1
|
@@ -4,10 +4,12 @@ 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
|
|
10
11
|
|
|
12
|
+
from ..backend_client import BackendRequestContext
|
|
11
13
|
from ..errors import QingflowApiError
|
|
12
14
|
from ..json_types import JSONObject
|
|
13
15
|
from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
|
|
@@ -16,7 +18,9 @@ from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
|
|
|
16
18
|
from ..solution.executor import extract_field_map, _build_viewgraph_questions
|
|
17
19
|
from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
|
|
18
20
|
from ..tools.app_tools import AppTools
|
|
21
|
+
from ..tools.directory_tools import DirectoryTools
|
|
19
22
|
from ..tools.package_tools import PackageTools
|
|
23
|
+
from ..tools.role_tools import RoleTools
|
|
20
24
|
from ..tools.solution_tools import SolutionTools
|
|
21
25
|
from ..tools.view_tools import ViewTools
|
|
22
26
|
from ..tools.workflow_tools import WorkflowTools
|
|
@@ -31,16 +35,20 @@ from .models import (
|
|
|
31
35
|
FieldSelector,
|
|
32
36
|
FieldUpdatePatch,
|
|
33
37
|
FlowPlanRequest,
|
|
38
|
+
FlowAssigneePatch,
|
|
34
39
|
LayoutApplyMode,
|
|
35
40
|
LayoutPlanRequest,
|
|
36
41
|
LayoutSectionPatch,
|
|
37
42
|
LayoutPreset,
|
|
38
43
|
PublicFieldType,
|
|
44
|
+
PublicViewType,
|
|
39
45
|
SchemaPlanRequest,
|
|
40
46
|
ViewUpsertPatch,
|
|
47
|
+
ViewFilterOperator,
|
|
41
48
|
ViewsPlanRequest,
|
|
42
49
|
ViewsPreset,
|
|
43
50
|
FlowPreset,
|
|
51
|
+
FlowNodePermissionsPatch,
|
|
44
52
|
)
|
|
45
53
|
|
|
46
54
|
|
|
@@ -62,6 +70,41 @@ QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
|
|
|
62
70
|
25: FieldType.relation.value,
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
|
|
74
|
+
FieldType.text.value: 2,
|
|
75
|
+
FieldType.long_text.value: 3,
|
|
76
|
+
FieldType.date.value: 4,
|
|
77
|
+
FieldType.datetime.value: 4,
|
|
78
|
+
FieldType.member.value: 5,
|
|
79
|
+
FieldType.email.value: 6,
|
|
80
|
+
FieldType.phone.value: 7,
|
|
81
|
+
FieldType.number.value: 8,
|
|
82
|
+
FieldType.amount.value: 8,
|
|
83
|
+
FieldType.boolean.value: 10,
|
|
84
|
+
FieldType.single_select.value: 11,
|
|
85
|
+
FieldType.multi_select.value: 12,
|
|
86
|
+
FieldType.attachment.value: 13,
|
|
87
|
+
FieldType.subtable.value: 18,
|
|
88
|
+
FieldType.address.value: 21,
|
|
89
|
+
FieldType.department.value: 22,
|
|
90
|
+
FieldType.relation.value: 25,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
MATCH_TYPE_ACCURACY = 1
|
|
94
|
+
JUDGE_EQUAL = 0
|
|
95
|
+
JUDGE_UNEQUAL = 1
|
|
96
|
+
JUDGE_GREATER_OR_EQUAL = 5
|
|
97
|
+
JUDGE_LESS_OR_EQUAL = 7
|
|
98
|
+
JUDGE_EQUAL_ANY = 9
|
|
99
|
+
JUDGE_FUZZY_MATCH = 19
|
|
100
|
+
JUDGE_INCLUDE_ANY = 20
|
|
101
|
+
|
|
102
|
+
INCLUDE_ANY_FLOW_FIELD_TYPES = {
|
|
103
|
+
FieldType.multi_select.value,
|
|
104
|
+
FieldType.member.value,
|
|
105
|
+
FieldType.department.value,
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
|
|
66
109
|
@dataclass(slots=True)
|
|
67
110
|
class ResolvedApp:
|
|
@@ -78,12 +121,16 @@ class AiBuilderFacade:
|
|
|
78
121
|
packages: PackageTools,
|
|
79
122
|
views: ViewTools,
|
|
80
123
|
workflows: WorkflowTools,
|
|
124
|
+
roles: RoleTools,
|
|
125
|
+
directory: DirectoryTools,
|
|
81
126
|
solutions: SolutionTools,
|
|
82
127
|
) -> None:
|
|
83
128
|
self.apps = apps
|
|
84
129
|
self.packages = packages
|
|
85
130
|
self.views = views
|
|
86
131
|
self.workflows = workflows
|
|
132
|
+
self.roles = roles
|
|
133
|
+
self.directory = directory
|
|
87
134
|
self.solutions = solutions
|
|
88
135
|
|
|
89
136
|
def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
|
|
@@ -134,6 +181,75 @@ class AiBuilderFacade:
|
|
|
134
181
|
"match_mode": "exact",
|
|
135
182
|
}
|
|
136
183
|
|
|
184
|
+
def package_create(self, *, profile: str, package_name: str) -> JSONObject:
|
|
185
|
+
requested = str(package_name or "").strip()
|
|
186
|
+
normalized_args = {"package_name": requested}
|
|
187
|
+
if not requested:
|
|
188
|
+
return _failed(
|
|
189
|
+
"PACKAGE_NAME_REQUIRED",
|
|
190
|
+
"package_name is required",
|
|
191
|
+
normalized_args=normalized_args,
|
|
192
|
+
suggested_next_call=None,
|
|
193
|
+
)
|
|
194
|
+
existing = self.package_resolve(profile=profile, package_name=requested)
|
|
195
|
+
if existing.get("status") == "success":
|
|
196
|
+
return {
|
|
197
|
+
"status": "success",
|
|
198
|
+
"error_code": None,
|
|
199
|
+
"recoverable": False,
|
|
200
|
+
"message": "package already exists",
|
|
201
|
+
"normalized_args": normalized_args,
|
|
202
|
+
"missing_fields": [],
|
|
203
|
+
"allowed_values": {},
|
|
204
|
+
"details": {},
|
|
205
|
+
"request_id": None,
|
|
206
|
+
"suggested_next_call": None,
|
|
207
|
+
"noop": True,
|
|
208
|
+
"verification": {"existing_package_reused": True},
|
|
209
|
+
"tag_id": existing.get("tag_id"),
|
|
210
|
+
"tag_name": existing.get("tag_name"),
|
|
211
|
+
}
|
|
212
|
+
if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
|
|
213
|
+
return existing
|
|
214
|
+
try:
|
|
215
|
+
created = self.packages.package_create(profile=profile, payload={"tagName": requested})
|
|
216
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
217
|
+
api_error = _coerce_api_error(error)
|
|
218
|
+
return _failed_from_api_error(
|
|
219
|
+
"PACKAGE_CREATE_FAILED",
|
|
220
|
+
api_error,
|
|
221
|
+
normalized_args=normalized_args,
|
|
222
|
+
details={"package_name": requested},
|
|
223
|
+
suggested_next_call={"tool_name": "package_create", "arguments": {"profile": profile, "package_name": requested}},
|
|
224
|
+
)
|
|
225
|
+
result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
226
|
+
tag_id = _coerce_positive_int(result.get("tagId"))
|
|
227
|
+
tag_name = str(result.get("tagName") or requested).strip() or requested
|
|
228
|
+
if tag_id is None:
|
|
229
|
+
resolved = self.package_resolve(profile=profile, package_name=requested)
|
|
230
|
+
if resolved.get("status") == "success":
|
|
231
|
+
tag_id = _coerce_positive_int(resolved.get("tag_id"))
|
|
232
|
+
tag_name = str(resolved.get("tag_name") or tag_name)
|
|
233
|
+
verified = tag_id is not None
|
|
234
|
+
return {
|
|
235
|
+
"status": "success" if verified else "partial_success",
|
|
236
|
+
"error_code": None,
|
|
237
|
+
"recoverable": False,
|
|
238
|
+
"message": "created package" if verified else "created package but could not verify tag id",
|
|
239
|
+
"normalized_args": normalized_args,
|
|
240
|
+
"missing_fields": [],
|
|
241
|
+
"allowed_values": {},
|
|
242
|
+
"details": {},
|
|
243
|
+
"request_id": None,
|
|
244
|
+
"suggested_next_call": None
|
|
245
|
+
if verified
|
|
246
|
+
else {"tool_name": "package_resolve", "arguments": {"profile": profile, "package_name": requested}},
|
|
247
|
+
"noop": False,
|
|
248
|
+
"verification": {"tag_id_verified": verified},
|
|
249
|
+
"tag_id": tag_id,
|
|
250
|
+
"tag_name": tag_name,
|
|
251
|
+
}
|
|
252
|
+
|
|
137
253
|
def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
|
|
138
254
|
listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
|
|
139
255
|
return {
|
|
@@ -156,6 +272,413 @@ class AiBuilderFacade:
|
|
|
156
272
|
"retried": bool(listed.get("retried", False)),
|
|
157
273
|
}
|
|
158
274
|
|
|
275
|
+
def member_search(self, *, profile: str, query: str, page_num: int = 1, page_size: int = 20, contain_disable: bool = False) -> JSONObject:
|
|
276
|
+
requested = str(query or "").strip()
|
|
277
|
+
normalized_args = {
|
|
278
|
+
"query": requested,
|
|
279
|
+
"page_num": page_num,
|
|
280
|
+
"page_size": page_size,
|
|
281
|
+
"contain_disable": contain_disable,
|
|
282
|
+
}
|
|
283
|
+
if not requested:
|
|
284
|
+
return _failed("MEMBER_QUERY_REQUIRED", "query is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
285
|
+
try:
|
|
286
|
+
listed = self.directory.directory_list_internal_users(
|
|
287
|
+
profile=profile,
|
|
288
|
+
keyword=requested,
|
|
289
|
+
dept_id=None,
|
|
290
|
+
role_id=None,
|
|
291
|
+
page_num=page_num,
|
|
292
|
+
page_size=page_size,
|
|
293
|
+
contain_disable=contain_disable,
|
|
294
|
+
)
|
|
295
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
296
|
+
api_error = _coerce_api_error(error)
|
|
297
|
+
return _failed_from_api_error(
|
|
298
|
+
"MEMBER_SEARCH_FAILED",
|
|
299
|
+
api_error,
|
|
300
|
+
normalized_args=normalized_args,
|
|
301
|
+
details={"query": requested},
|
|
302
|
+
suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, **normalized_args}},
|
|
303
|
+
)
|
|
304
|
+
items = []
|
|
305
|
+
for item in _extract_directory_items(listed):
|
|
306
|
+
uid = _coerce_positive_int(item.get("uid") or item.get("id"))
|
|
307
|
+
if uid is None:
|
|
308
|
+
continue
|
|
309
|
+
items.append(
|
|
310
|
+
{
|
|
311
|
+
"uid": uid,
|
|
312
|
+
"name": item.get("nickName") or item.get("name") or item.get("value"),
|
|
313
|
+
"email": item.get("email"),
|
|
314
|
+
"dept_name": item.get("deptName") or item.get("departName"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
return {
|
|
318
|
+
"status": "success",
|
|
319
|
+
"error_code": None,
|
|
320
|
+
"recoverable": False,
|
|
321
|
+
"message": "resolved members",
|
|
322
|
+
"normalized_args": normalized_args,
|
|
323
|
+
"missing_fields": [],
|
|
324
|
+
"allowed_values": {},
|
|
325
|
+
"details": {},
|
|
326
|
+
"request_id": None,
|
|
327
|
+
"suggested_next_call": None,
|
|
328
|
+
"noop": False,
|
|
329
|
+
"verification": {"count": len(items)},
|
|
330
|
+
"items": items,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def role_search(self, *, profile: str, keyword: str, page_num: int = 1, page_size: int = 20) -> JSONObject:
|
|
334
|
+
requested = str(keyword or "").strip()
|
|
335
|
+
normalized_args = {"keyword": requested, "page_num": page_num, "page_size": page_size}
|
|
336
|
+
if not requested:
|
|
337
|
+
return _failed("ROLE_QUERY_REQUIRED", "keyword is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
338
|
+
try:
|
|
339
|
+
listed = self.roles.role_search(profile=profile, keyword=requested, page_num=page_num, page_size=page_size)
|
|
340
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
341
|
+
api_error = _coerce_api_error(error)
|
|
342
|
+
return _failed_from_api_error(
|
|
343
|
+
"ROLE_SEARCH_FAILED",
|
|
344
|
+
api_error,
|
|
345
|
+
normalized_args=normalized_args,
|
|
346
|
+
details={"keyword": requested},
|
|
347
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
|
|
348
|
+
)
|
|
349
|
+
page = listed.get("page") if isinstance(listed.get("page"), dict) else {}
|
|
350
|
+
raw_items = page.get("list") if isinstance(page.get("list"), list) else []
|
|
351
|
+
items = []
|
|
352
|
+
for item in raw_items:
|
|
353
|
+
if not isinstance(item, dict):
|
|
354
|
+
continue
|
|
355
|
+
role_id = _coerce_positive_int(item.get("roleId") or item.get("id"))
|
|
356
|
+
if role_id is None:
|
|
357
|
+
continue
|
|
358
|
+
items.append(
|
|
359
|
+
{
|
|
360
|
+
"role_id": role_id,
|
|
361
|
+
"role_name": item.get("roleName") or item.get("name"),
|
|
362
|
+
"role_icon": item.get("roleIcon"),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
return {
|
|
366
|
+
"status": "success",
|
|
367
|
+
"error_code": None,
|
|
368
|
+
"recoverable": False,
|
|
369
|
+
"message": "resolved roles",
|
|
370
|
+
"normalized_args": normalized_args,
|
|
371
|
+
"missing_fields": [],
|
|
372
|
+
"allowed_values": {},
|
|
373
|
+
"details": {},
|
|
374
|
+
"request_id": None,
|
|
375
|
+
"suggested_next_call": None,
|
|
376
|
+
"noop": False,
|
|
377
|
+
"verification": {"count": len(items)},
|
|
378
|
+
"items": items,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
def role_create(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
profile: str,
|
|
385
|
+
role_name: str,
|
|
386
|
+
member_uids: list[int],
|
|
387
|
+
member_emails: list[str],
|
|
388
|
+
member_names: list[str],
|
|
389
|
+
role_icon: str = "ex-user-outlined",
|
|
390
|
+
) -> JSONObject:
|
|
391
|
+
normalized_args = {
|
|
392
|
+
"role_name": str(role_name or "").strip(),
|
|
393
|
+
"member_uids": [uid for uid in member_uids if isinstance(uid, int) and uid > 0],
|
|
394
|
+
"member_emails": [str(email or "").strip() for email in member_emails if str(email or "").strip()],
|
|
395
|
+
"member_names": [str(name or "").strip() for name in member_names if str(name or "").strip()],
|
|
396
|
+
"role_icon": role_icon or "ex-user-outlined",
|
|
397
|
+
}
|
|
398
|
+
requested_name = normalized_args["role_name"]
|
|
399
|
+
if not requested_name:
|
|
400
|
+
return _failed("ROLE_NAME_REQUIRED", "role_name is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
401
|
+
existing = self.role_search(profile=profile, keyword=requested_name, page_num=1, page_size=50)
|
|
402
|
+
if existing.get("status") == "success":
|
|
403
|
+
exact = [item for item in existing.get("items", []) if isinstance(item, dict) and item.get("role_name") == requested_name]
|
|
404
|
+
if len(exact) == 1:
|
|
405
|
+
return {
|
|
406
|
+
"status": "success",
|
|
407
|
+
"error_code": None,
|
|
408
|
+
"recoverable": False,
|
|
409
|
+
"message": "role already exists",
|
|
410
|
+
"normalized_args": normalized_args,
|
|
411
|
+
"missing_fields": [],
|
|
412
|
+
"allowed_values": {},
|
|
413
|
+
"details": {},
|
|
414
|
+
"request_id": None,
|
|
415
|
+
"suggested_next_call": None,
|
|
416
|
+
"noop": True,
|
|
417
|
+
"verification": {"existing_role_reused": True},
|
|
418
|
+
"role_id": exact[0]["role_id"],
|
|
419
|
+
"role_name": exact[0]["role_name"],
|
|
420
|
+
"role_icon": exact[0].get("role_icon"),
|
|
421
|
+
}
|
|
422
|
+
if len(exact) > 1:
|
|
423
|
+
return _failed(
|
|
424
|
+
"AMBIGUOUS_ROLE",
|
|
425
|
+
f"multiple roles matched '{requested_name}'",
|
|
426
|
+
normalized_args=normalized_args,
|
|
427
|
+
details={"matches": exact},
|
|
428
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": requested_name}},
|
|
429
|
+
)
|
|
430
|
+
resolved_members = self._resolve_member_references(
|
|
431
|
+
profile=profile,
|
|
432
|
+
member_uids=normalized_args["member_uids"],
|
|
433
|
+
member_emails=normalized_args["member_emails"],
|
|
434
|
+
member_names=normalized_args["member_names"],
|
|
435
|
+
)
|
|
436
|
+
if resolved_members["issues"]:
|
|
437
|
+
first_issue = resolved_members["issues"][0]
|
|
438
|
+
return _failed(
|
|
439
|
+
"ROLE_MEMBERS_UNRESOLVED",
|
|
440
|
+
"one or more role members could not be resolved",
|
|
441
|
+
normalized_args=normalized_args,
|
|
442
|
+
details={"issues": resolved_members["issues"]},
|
|
443
|
+
suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}},
|
|
444
|
+
)
|
|
445
|
+
try:
|
|
446
|
+
created = self.roles.role_create(
|
|
447
|
+
profile=profile,
|
|
448
|
+
payload={
|
|
449
|
+
"roleName": requested_name,
|
|
450
|
+
"roleIcon": normalized_args["role_icon"],
|
|
451
|
+
"users": resolved_members["member_uids"],
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
455
|
+
api_error = _coerce_api_error(error)
|
|
456
|
+
return _failed_from_api_error(
|
|
457
|
+
"ROLE_CREATE_FAILED",
|
|
458
|
+
api_error,
|
|
459
|
+
normalized_args=normalized_args,
|
|
460
|
+
details={"role_name": requested_name},
|
|
461
|
+
suggested_next_call={"tool_name": "role_create", "arguments": {"profile": profile, **normalized_args}},
|
|
462
|
+
)
|
|
463
|
+
role_result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
464
|
+
role_id = _coerce_positive_int(role_result.get("roleId") or role_result.get("id"))
|
|
465
|
+
return {
|
|
466
|
+
"status": "success",
|
|
467
|
+
"error_code": None,
|
|
468
|
+
"recoverable": False,
|
|
469
|
+
"message": "created role",
|
|
470
|
+
"normalized_args": normalized_args,
|
|
471
|
+
"missing_fields": [],
|
|
472
|
+
"allowed_values": {},
|
|
473
|
+
"details": {},
|
|
474
|
+
"request_id": None,
|
|
475
|
+
"suggested_next_call": None,
|
|
476
|
+
"noop": False,
|
|
477
|
+
"verification": {"member_count": len(resolved_members["member_uids"])},
|
|
478
|
+
"role_id": role_id,
|
|
479
|
+
"role_name": requested_name,
|
|
480
|
+
"role_icon": normalized_args["role_icon"],
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
def _resolve_role_references(
|
|
484
|
+
self,
|
|
485
|
+
*,
|
|
486
|
+
profile: str,
|
|
487
|
+
role_ids: list[int],
|
|
488
|
+
role_names: list[str],
|
|
489
|
+
) -> dict[str, Any]:
|
|
490
|
+
issues: list[dict[str, Any]] = []
|
|
491
|
+
resolved: list[dict[str, Any]] = []
|
|
492
|
+
seen_ids: set[int] = set()
|
|
493
|
+
for role_id in role_ids:
|
|
494
|
+
normalized_role_id = _coerce_positive_int(role_id)
|
|
495
|
+
if normalized_role_id is None or normalized_role_id in seen_ids:
|
|
496
|
+
continue
|
|
497
|
+
resolved.append(
|
|
498
|
+
{
|
|
499
|
+
"roleId": normalized_role_id,
|
|
500
|
+
"roleName": str(normalized_role_id),
|
|
501
|
+
"roleIcon": "ex-user-outlined",
|
|
502
|
+
"beingFrontendConfig": True,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
seen_ids.add(normalized_role_id)
|
|
506
|
+
for role_name in role_names:
|
|
507
|
+
requested = str(role_name or "").strip()
|
|
508
|
+
if not requested:
|
|
509
|
+
continue
|
|
510
|
+
matches_result = self.role_search(profile=profile, keyword=requested, page_num=1, page_size=50)
|
|
511
|
+
items = matches_result.get("items", []) if matches_result.get("status") == "success" else []
|
|
512
|
+
exact = [item for item in items if isinstance(item, dict) and item.get("role_name") == requested]
|
|
513
|
+
if len(exact) != 1:
|
|
514
|
+
issues.append(
|
|
515
|
+
{
|
|
516
|
+
"kind": "role",
|
|
517
|
+
"value": requested,
|
|
518
|
+
"error_code": "AMBIGUOUS_ROLE" if len(exact) > 1 else "ROLE_NOT_FOUND",
|
|
519
|
+
"matches": exact,
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
continue
|
|
523
|
+
role_id = _coerce_positive_int(exact[0].get("role_id"))
|
|
524
|
+
if role_id is None or role_id in seen_ids:
|
|
525
|
+
continue
|
|
526
|
+
resolved.append(
|
|
527
|
+
{
|
|
528
|
+
"roleId": role_id,
|
|
529
|
+
"roleName": exact[0].get("role_name") or requested,
|
|
530
|
+
"roleIcon": exact[0].get("role_icon") or "ex-user-outlined",
|
|
531
|
+
"beingFrontendConfig": True,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
seen_ids.add(role_id)
|
|
535
|
+
return {"role_entries": resolved, "issues": issues}
|
|
536
|
+
|
|
537
|
+
def _resolve_member_references(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
profile: str,
|
|
541
|
+
member_uids: list[int],
|
|
542
|
+
member_emails: list[str],
|
|
543
|
+
member_names: list[str],
|
|
544
|
+
) -> dict[str, Any]:
|
|
545
|
+
issues: list[dict[str, Any]] = []
|
|
546
|
+
resolved: list[dict[str, Any]] = []
|
|
547
|
+
seen_uids: set[int] = set()
|
|
548
|
+
|
|
549
|
+
def add_member(item: dict[str, Any], *, fallback_name: str | None = None) -> None:
|
|
550
|
+
uid = _coerce_positive_int(item.get("uid") or item.get("id"))
|
|
551
|
+
if uid is None or uid in seen_uids:
|
|
552
|
+
return
|
|
553
|
+
resolved.append(
|
|
554
|
+
{
|
|
555
|
+
"uid": uid,
|
|
556
|
+
"name": item.get("nickName") or item.get("name") or fallback_name or str(uid),
|
|
557
|
+
"email": item.get("email"),
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
seen_uids.add(uid)
|
|
561
|
+
|
|
562
|
+
for uid in member_uids:
|
|
563
|
+
normalized_uid = _coerce_positive_int(uid)
|
|
564
|
+
if normalized_uid is not None and normalized_uid not in seen_uids:
|
|
565
|
+
add_member({"uid": normalized_uid}, fallback_name=str(normalized_uid))
|
|
566
|
+
|
|
567
|
+
for email in member_emails:
|
|
568
|
+
requested = str(email or "").strip()
|
|
569
|
+
if not requested:
|
|
570
|
+
continue
|
|
571
|
+
matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
|
|
572
|
+
items = matches.get("items", []) if matches.get("status") == "success" else []
|
|
573
|
+
exact = [item for item in items if isinstance(item, dict) and str(item.get("email") or "").strip().lower() == requested.lower()]
|
|
574
|
+
if len(exact) != 1:
|
|
575
|
+
issues.append(
|
|
576
|
+
{
|
|
577
|
+
"kind": "member_email",
|
|
578
|
+
"value": requested,
|
|
579
|
+
"error_code": "AMBIGUOUS_MEMBER" if len(exact) > 1 else "MEMBER_NOT_FOUND",
|
|
580
|
+
"matches": exact,
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
continue
|
|
584
|
+
add_member(exact[0])
|
|
585
|
+
|
|
586
|
+
for name in member_names:
|
|
587
|
+
requested = str(name or "").strip()
|
|
588
|
+
if not requested:
|
|
589
|
+
continue
|
|
590
|
+
matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
|
|
591
|
+
items = matches.get("items", []) if matches.get("status") == "success" else []
|
|
592
|
+
exact = [item for item in items if isinstance(item, dict) and str(item.get("name") or "").strip() == requested]
|
|
593
|
+
if len(exact) != 1:
|
|
594
|
+
issues.append(
|
|
595
|
+
{
|
|
596
|
+
"kind": "member_name",
|
|
597
|
+
"value": requested,
|
|
598
|
+
"error_code": "AMBIGUOUS_MEMBER" if len(exact) > 1 else "MEMBER_NOT_FOUND",
|
|
599
|
+
"matches": exact,
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
continue
|
|
603
|
+
add_member(exact[0])
|
|
604
|
+
|
|
605
|
+
return {"member_uids": [item["uid"] for item in resolved], "member_entries": resolved, "issues": issues}
|
|
606
|
+
|
|
607
|
+
def _normalize_flow_nodes(
|
|
608
|
+
self,
|
|
609
|
+
*,
|
|
610
|
+
profile: str,
|
|
611
|
+
current_fields: list[dict[str, Any]],
|
|
612
|
+
nodes: list[dict[str, Any]],
|
|
613
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
614
|
+
field_name_to_field = {
|
|
615
|
+
str(field.get("name") or ""): field
|
|
616
|
+
for field in current_fields
|
|
617
|
+
if str(field.get("name") or "")
|
|
618
|
+
}
|
|
619
|
+
field_name_to_que_id = {
|
|
620
|
+
str(field.get("name") or ""): int(field.get("que_id"))
|
|
621
|
+
for field in current_fields
|
|
622
|
+
if str(field.get("name") or "") and isinstance(field.get("que_id"), int)
|
|
623
|
+
}
|
|
624
|
+
normalized_nodes: list[dict[str, Any]] = []
|
|
625
|
+
issues: list[dict[str, Any]] = []
|
|
626
|
+
for node in nodes:
|
|
627
|
+
if not isinstance(node, dict):
|
|
628
|
+
continue
|
|
629
|
+
normalized_node = deepcopy(node)
|
|
630
|
+
assignees = FlowAssigneePatch.model_validate(node.get("assignees") or {})
|
|
631
|
+
permissions = FlowNodePermissionsPatch.model_validate(node.get("permissions") or {})
|
|
632
|
+
role_resolution = self._resolve_role_references(
|
|
633
|
+
profile=profile,
|
|
634
|
+
role_ids=assignees.role_ids,
|
|
635
|
+
role_names=assignees.role_names,
|
|
636
|
+
)
|
|
637
|
+
member_resolution = self._resolve_member_references(
|
|
638
|
+
profile=profile,
|
|
639
|
+
member_uids=assignees.member_uids,
|
|
640
|
+
member_emails=assignees.member_emails,
|
|
641
|
+
member_names=assignees.member_names,
|
|
642
|
+
)
|
|
643
|
+
issues.extend({**issue, "node_id": node.get("id")} for issue in [*role_resolution["issues"], *member_resolution["issues"]])
|
|
644
|
+
editable_que_ids: list[int] = []
|
|
645
|
+
missing_editable_fields: list[str] = []
|
|
646
|
+
for field_name in permissions.editable_fields:
|
|
647
|
+
if field_name not in field_name_to_que_id:
|
|
648
|
+
missing_editable_fields.append(field_name)
|
|
649
|
+
else:
|
|
650
|
+
editable_que_ids.append(field_name_to_que_id[field_name])
|
|
651
|
+
if missing_editable_fields:
|
|
652
|
+
issues.append(
|
|
653
|
+
{
|
|
654
|
+
"node_id": node.get("id"),
|
|
655
|
+
"kind": "editable_fields",
|
|
656
|
+
"error_code": "UNKNOWN_FLOW_FIELD",
|
|
657
|
+
"missing_fields": missing_editable_fields,
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
condition_matrix, condition_issues = _build_flow_condition_matrix(
|
|
661
|
+
current_fields_by_name=field_name_to_field,
|
|
662
|
+
node=normalized_node,
|
|
663
|
+
)
|
|
664
|
+
issues.extend({**issue, "node_id": node.get("id")} for issue in condition_issues)
|
|
665
|
+
config_payload = deepcopy(normalized_node.get("config") or {}) if isinstance(normalized_node.get("config"), dict) else {}
|
|
666
|
+
if condition_matrix:
|
|
667
|
+
config_payload["conditionFormatMatrix"] = condition_matrix
|
|
668
|
+
normalized_node["assignees"] = {
|
|
669
|
+
"member_uids": member_resolution["member_uids"],
|
|
670
|
+
"role_entries": role_resolution["role_entries"],
|
|
671
|
+
"include_sub_departs": assignees.include_sub_departs,
|
|
672
|
+
}
|
|
673
|
+
normalized_node["permissions"] = {
|
|
674
|
+
"editable_fields": permissions.editable_fields,
|
|
675
|
+
"editable_que_ids": editable_que_ids,
|
|
676
|
+
}
|
|
677
|
+
if config_payload:
|
|
678
|
+
normalized_node["config"] = config_payload
|
|
679
|
+
normalized_nodes.append(normalized_node)
|
|
680
|
+
return normalized_nodes, issues
|
|
681
|
+
|
|
159
682
|
def package_attach_app(
|
|
160
683
|
self,
|
|
161
684
|
*,
|
|
@@ -209,6 +732,115 @@ class AiBuilderFacade:
|
|
|
209
732
|
"attached": attached,
|
|
210
733
|
}
|
|
211
734
|
|
|
735
|
+
def app_release_edit_lock_if_mine(
|
|
736
|
+
self,
|
|
737
|
+
*,
|
|
738
|
+
profile: str,
|
|
739
|
+
app_key: str,
|
|
740
|
+
lock_owner_email: str = "",
|
|
741
|
+
lock_owner_name: str = "",
|
|
742
|
+
) -> JSONObject:
|
|
743
|
+
normalized_args = {
|
|
744
|
+
"app_key": app_key,
|
|
745
|
+
"lock_owner_email": lock_owner_email,
|
|
746
|
+
"lock_owner_name": lock_owner_name,
|
|
747
|
+
}
|
|
748
|
+
session_profile = self.apps.sessions.get_profile(profile)
|
|
749
|
+
if session_profile is None:
|
|
750
|
+
return _failed(
|
|
751
|
+
"AUTH_REQUIRED",
|
|
752
|
+
"auth profile is required before releasing an app edit lock",
|
|
753
|
+
normalized_args=normalized_args,
|
|
754
|
+
recoverable=False,
|
|
755
|
+
suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
|
|
756
|
+
)
|
|
757
|
+
identity = self._resolve_current_user_identity(profile=profile)
|
|
758
|
+
current_email = str(identity.get("email") or "").strip().lower()
|
|
759
|
+
current_name = str(identity.get("nick_name") or "").strip()
|
|
760
|
+
requested_owner_email = str(lock_owner_email or "").strip().lower()
|
|
761
|
+
requested_owner_name = str(lock_owner_name or "").strip()
|
|
762
|
+
if not requested_owner_email and not requested_owner_name:
|
|
763
|
+
return _failed(
|
|
764
|
+
"EDIT_LOCK_OWNER_UNKNOWN",
|
|
765
|
+
"lock owner could not be verified; refuse to release edit lock blindly",
|
|
766
|
+
normalized_args=normalized_args,
|
|
767
|
+
recoverable=False,
|
|
768
|
+
details={
|
|
769
|
+
"current_user_email": identity.get("email"),
|
|
770
|
+
"current_user_name": identity.get("nick_name"),
|
|
771
|
+
},
|
|
772
|
+
suggested_next_call=None,
|
|
773
|
+
)
|
|
774
|
+
owner_matches = True
|
|
775
|
+
if requested_owner_email:
|
|
776
|
+
owner_matches = bool(current_email) and current_email == requested_owner_email
|
|
777
|
+
elif requested_owner_name:
|
|
778
|
+
owner_matches = bool(current_name) and current_name == requested_owner_name
|
|
779
|
+
if not owner_matches:
|
|
780
|
+
return _failed(
|
|
781
|
+
"EDIT_LOCK_HELD_BY_OTHER_USER",
|
|
782
|
+
"edit lock is owned by another user; refusing to release it",
|
|
783
|
+
normalized_args=normalized_args,
|
|
784
|
+
recoverable=False,
|
|
785
|
+
details={
|
|
786
|
+
"lock_owner_email": requested_owner_email or None,
|
|
787
|
+
"lock_owner_name": requested_owner_name or None,
|
|
788
|
+
"current_user_email": identity.get("email"),
|
|
789
|
+
"current_user_name": identity.get("nick_name"),
|
|
790
|
+
},
|
|
791
|
+
suggested_next_call=None,
|
|
792
|
+
)
|
|
793
|
+
try:
|
|
794
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
795
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
796
|
+
api_error = _coerce_api_error(error)
|
|
797
|
+
return _failed_from_api_error(
|
|
798
|
+
"EDIT_LOCK_RELEASE_FAILED",
|
|
799
|
+
api_error,
|
|
800
|
+
normalized_args=normalized_args,
|
|
801
|
+
details={"app_key": app_key},
|
|
802
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
803
|
+
)
|
|
804
|
+
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
805
|
+
try:
|
|
806
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
807
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
808
|
+
api_error = _coerce_api_error(error)
|
|
809
|
+
return _failed_from_api_error(
|
|
810
|
+
"EDIT_LOCK_RELEASE_FAILED",
|
|
811
|
+
api_error,
|
|
812
|
+
normalized_args=normalized_args,
|
|
813
|
+
details={
|
|
814
|
+
"app_key": app_key,
|
|
815
|
+
"edit_version_no": edit_version_no,
|
|
816
|
+
"lock_owner_email": requested_owner_email or None,
|
|
817
|
+
"lock_owner_name": requested_owner_name or None,
|
|
818
|
+
},
|
|
819
|
+
suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
|
|
820
|
+
)
|
|
821
|
+
return {
|
|
822
|
+
"status": "success",
|
|
823
|
+
"error_code": None,
|
|
824
|
+
"recoverable": False,
|
|
825
|
+
"message": "released app edit lock owned by current user",
|
|
826
|
+
"normalized_args": normalized_args,
|
|
827
|
+
"missing_fields": [],
|
|
828
|
+
"allowed_values": {},
|
|
829
|
+
"details": {
|
|
830
|
+
"lock_owner_email": requested_owner_email or None,
|
|
831
|
+
"lock_owner_name": requested_owner_name or None,
|
|
832
|
+
"current_user_email": identity.get("email"),
|
|
833
|
+
"current_user_name": identity.get("nick_name"),
|
|
834
|
+
"edit_version_no": edit_version_no,
|
|
835
|
+
},
|
|
836
|
+
"request_id": None,
|
|
837
|
+
"suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
838
|
+
"noop": False,
|
|
839
|
+
"verification": {"released": True},
|
|
840
|
+
"app_key": app_key,
|
|
841
|
+
"released": True,
|
|
842
|
+
}
|
|
843
|
+
|
|
212
844
|
def app_resolve(
|
|
213
845
|
self,
|
|
214
846
|
*,
|
|
@@ -220,8 +852,14 @@ class AiBuilderFacade:
|
|
|
220
852
|
if app_key:
|
|
221
853
|
try:
|
|
222
854
|
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
223
|
-
except RuntimeError as exc:
|
|
224
|
-
|
|
855
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
856
|
+
api_error = _coerce_api_error(exc)
|
|
857
|
+
return _failed_from_api_error(
|
|
858
|
+
"APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
|
|
859
|
+
api_error,
|
|
860
|
+
details={"app_key": app_key},
|
|
861
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
862
|
+
)
|
|
225
863
|
result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
226
864
|
return {
|
|
227
865
|
"status": "success",
|
|
@@ -298,8 +936,30 @@ class AiBuilderFacade:
|
|
|
298
936
|
}
|
|
299
937
|
|
|
300
938
|
def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
301
|
-
|
|
939
|
+
try:
|
|
940
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
941
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
942
|
+
api_error = _coerce_api_error(error)
|
|
943
|
+
return _failed_from_api_error(
|
|
944
|
+
"APP_READ_FAILED",
|
|
945
|
+
api_error,
|
|
946
|
+
normalized_args={"app_key": app_key},
|
|
947
|
+
details={"app_key": app_key},
|
|
948
|
+
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
949
|
+
)
|
|
950
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
951
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
302
952
|
parsed = state["parsed"]
|
|
953
|
+
verification_hints = _build_verification_hints(
|
|
954
|
+
tag_ids=_coerce_int_list(state["base"].get("tagIds")),
|
|
955
|
+
fields=parsed["fields"],
|
|
956
|
+
layout=parsed["layout"],
|
|
957
|
+
views=_summarize_views(views),
|
|
958
|
+
)
|
|
959
|
+
if views_unavailable:
|
|
960
|
+
verification_hints.append("views_read_unavailable")
|
|
961
|
+
if workflow_unavailable:
|
|
962
|
+
verification_hints.append("workflow_read_unavailable")
|
|
303
963
|
response = AppReadSummaryResponse(
|
|
304
964
|
app_key=app_key,
|
|
305
965
|
title=state["base"].get("formTitle"),
|
|
@@ -307,14 +967,9 @@ class AiBuilderFacade:
|
|
|
307
967
|
publish_status=state["base"].get("appPublishStatus"),
|
|
308
968
|
field_count=len(parsed["fields"]),
|
|
309
969
|
layout_section_count=len(parsed["layout"].get("sections", [])),
|
|
310
|
-
view_count=len(_summarize_views(
|
|
311
|
-
workflow_enabled=bool(
|
|
312
|
-
verification_hints=
|
|
313
|
-
tag_ids=_coerce_int_list(state["base"].get("tagIds")),
|
|
314
|
-
fields=parsed["fields"],
|
|
315
|
-
layout=parsed["layout"],
|
|
316
|
-
views=_summarize_views(state["views"]),
|
|
317
|
-
),
|
|
970
|
+
view_count=len(_summarize_views(views)),
|
|
971
|
+
workflow_enabled=bool(workflow),
|
|
972
|
+
verification_hints=verification_hints,
|
|
318
973
|
)
|
|
319
974
|
return {
|
|
320
975
|
"status": "success",
|
|
@@ -333,7 +988,17 @@ class AiBuilderFacade:
|
|
|
333
988
|
}
|
|
334
989
|
|
|
335
990
|
def app_read_fields(self, *, profile: str, app_key: str) -> JSONObject:
|
|
336
|
-
|
|
991
|
+
try:
|
|
992
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
993
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
994
|
+
api_error = _coerce_api_error(error)
|
|
995
|
+
return _failed_from_api_error(
|
|
996
|
+
"FIELDS_READ_FAILED",
|
|
997
|
+
api_error,
|
|
998
|
+
normalized_args={"app_key": app_key},
|
|
999
|
+
details={"app_key": app_key},
|
|
1000
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1001
|
+
)
|
|
337
1002
|
parsed = state["parsed"]
|
|
338
1003
|
response = AppFieldsReadResponse(
|
|
339
1004
|
app_key=app_key,
|
|
@@ -367,7 +1032,17 @@ class AiBuilderFacade:
|
|
|
367
1032
|
}
|
|
368
1033
|
|
|
369
1034
|
def app_read_layout_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
370
|
-
|
|
1035
|
+
try:
|
|
1036
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
1037
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1038
|
+
api_error = _coerce_api_error(error)
|
|
1039
|
+
return _failed_from_api_error(
|
|
1040
|
+
"LAYOUT_READ_FAILED",
|
|
1041
|
+
api_error,
|
|
1042
|
+
normalized_args={"app_key": app_key},
|
|
1043
|
+
details={"app_key": app_key},
|
|
1044
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1045
|
+
)
|
|
371
1046
|
parsed = state["parsed"]
|
|
372
1047
|
layout = parsed["layout"]
|
|
373
1048
|
response = AppLayoutReadResponse(
|
|
@@ -393,10 +1068,20 @@ class AiBuilderFacade:
|
|
|
393
1068
|
}
|
|
394
1069
|
|
|
395
1070
|
def app_read_views_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
396
|
-
|
|
1071
|
+
try:
|
|
1072
|
+
views, _ = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
|
|
1073
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1074
|
+
api_error = _coerce_api_error(error)
|
|
1075
|
+
return _failed_from_api_error(
|
|
1076
|
+
"VIEWS_READ_FAILED",
|
|
1077
|
+
api_error,
|
|
1078
|
+
normalized_args={"app_key": app_key},
|
|
1079
|
+
details={"app_key": app_key},
|
|
1080
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1081
|
+
)
|
|
397
1082
|
response = AppViewsReadResponse(
|
|
398
1083
|
app_key=app_key,
|
|
399
|
-
views=_summarize_views(
|
|
1084
|
+
views=_summarize_views(views),
|
|
400
1085
|
)
|
|
401
1086
|
return {
|
|
402
1087
|
"status": "success",
|
|
@@ -415,11 +1100,21 @@ class AiBuilderFacade:
|
|
|
415
1100
|
}
|
|
416
1101
|
|
|
417
1102
|
def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
418
|
-
|
|
1103
|
+
try:
|
|
1104
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
1105
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1106
|
+
api_error = _coerce_api_error(error)
|
|
1107
|
+
return _failed_from_api_error(
|
|
1108
|
+
"FLOW_READ_FAILED",
|
|
1109
|
+
api_error,
|
|
1110
|
+
normalized_args={"app_key": app_key},
|
|
1111
|
+
details={"app_key": app_key},
|
|
1112
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1113
|
+
)
|
|
419
1114
|
response = AppFlowReadResponse(
|
|
420
1115
|
app_key=app_key,
|
|
421
|
-
enabled=bool(
|
|
422
|
-
nodes=_summarize_workflow_nodes(
|
|
1116
|
+
enabled=bool(workflow),
|
|
1117
|
+
nodes=_summarize_workflow_nodes(workflow),
|
|
423
1118
|
transitions=[],
|
|
424
1119
|
)
|
|
425
1120
|
return {
|
|
@@ -434,7 +1129,7 @@ class AiBuilderFacade:
|
|
|
434
1129
|
"request_id": None,
|
|
435
1130
|
"suggested_next_call": None,
|
|
436
1131
|
"noop": False,
|
|
437
|
-
"verification": {"app_exists": True},
|
|
1132
|
+
"verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
|
|
438
1133
|
**response.model_dump(mode="json"),
|
|
439
1134
|
}
|
|
440
1135
|
|
|
@@ -452,7 +1147,11 @@ class AiBuilderFacade:
|
|
|
452
1147
|
return target
|
|
453
1148
|
current_fields: list[dict[str, Any]] = []
|
|
454
1149
|
if not bool(target.get("would_create")):
|
|
455
|
-
|
|
1150
|
+
fields_result = self.app_read_fields(profile=profile, app_key=str(target["app_key"]))
|
|
1151
|
+
if fields_result.get("status") == "failed":
|
|
1152
|
+
fields_result.setdefault("normalized_args", normalized_args)
|
|
1153
|
+
return fields_result
|
|
1154
|
+
current_fields = fields_result.get("fields", [])
|
|
456
1155
|
current_by_name = {str(field.get("name") or ""): field for field in current_fields}
|
|
457
1156
|
blocking_issues: list[dict[str, Any]] = []
|
|
458
1157
|
preview_added: list[str] = []
|
|
@@ -510,8 +1209,12 @@ class AiBuilderFacade:
|
|
|
510
1209
|
|
|
511
1210
|
def app_layout_plan(self, *, profile: str, request: LayoutPlanRequest) -> JSONObject:
|
|
512
1211
|
read_fields = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
1212
|
+
if read_fields.get("status") == "failed":
|
|
1213
|
+
return read_fields
|
|
513
1214
|
current_names = [str(field.get("name") or "") for field in read_fields.get("fields", []) if field.get("name")]
|
|
514
1215
|
current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
|
|
1216
|
+
if current_layout.get("status") == "failed":
|
|
1217
|
+
return current_layout
|
|
515
1218
|
requested_sections = [section.model_dump(mode="json") for section in request.sections]
|
|
516
1219
|
if request.preset is not None:
|
|
517
1220
|
requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
|
|
@@ -573,10 +1276,65 @@ class AiBuilderFacade:
|
|
|
573
1276
|
nodes = [node.model_dump(mode="json") for node in request.nodes]
|
|
574
1277
|
transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
|
|
575
1278
|
if request.preset is not None:
|
|
576
|
-
|
|
577
|
-
|
|
1279
|
+
preset_nodes, preset_transitions = _build_flow_preset(request.preset)
|
|
1280
|
+
nodes, transitions = _merge_flow_graph(
|
|
1281
|
+
base_nodes=preset_nodes,
|
|
1282
|
+
base_transitions=preset_transitions,
|
|
1283
|
+
override_nodes=nodes,
|
|
1284
|
+
override_transitions=transitions,
|
|
1285
|
+
)
|
|
1286
|
+
fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
1287
|
+
if fields_result.get("status") == "failed":
|
|
1288
|
+
return fields_result
|
|
1289
|
+
current_fields = fields_result.get("fields", [])
|
|
1290
|
+
nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
|
|
1291
|
+
if resolution_issues:
|
|
1292
|
+
first_issue = resolution_issues[0]
|
|
1293
|
+
suggested_call = None
|
|
1294
|
+
if first_issue.get("kind", "").startswith("role"):
|
|
1295
|
+
suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
|
|
1296
|
+
elif first_issue.get("kind", "").startswith("member"):
|
|
1297
|
+
suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
|
|
1298
|
+
elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
|
|
1299
|
+
suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": request.app_key}}
|
|
1300
|
+
return _failed(
|
|
1301
|
+
first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
|
|
1302
|
+
"workflow contains unresolved assignees or field permissions",
|
|
1303
|
+
normalized_args={
|
|
1304
|
+
"app_key": request.app_key,
|
|
1305
|
+
"mode": str(request.mode or "replace"),
|
|
1306
|
+
"preset": request.preset.value if request.preset else None,
|
|
1307
|
+
"nodes": nodes,
|
|
1308
|
+
"transitions": transitions,
|
|
1309
|
+
},
|
|
1310
|
+
details={"issues": resolution_issues},
|
|
1311
|
+
suggested_next_call=suggested_call,
|
|
1312
|
+
)
|
|
578
1313
|
status_field_present = _infer_status_field_id(current_fields) is not None
|
|
579
1314
|
node_types = {str(node.get("type") or "") for node in nodes}
|
|
1315
|
+
assignee_required_nodes = [
|
|
1316
|
+
node.get("id")
|
|
1317
|
+
for node in nodes
|
|
1318
|
+
if str(node.get("type") or "") in {"approve", "fill", "copy"}
|
|
1319
|
+
and not (
|
|
1320
|
+
(node.get("assignees") or {}).get("role_entries")
|
|
1321
|
+
or (node.get("assignees") or {}).get("member_uids")
|
|
1322
|
+
)
|
|
1323
|
+
]
|
|
1324
|
+
if assignee_required_nodes:
|
|
1325
|
+
return _failed(
|
|
1326
|
+
"FLOW_ASSIGNEE_REQUIRED",
|
|
1327
|
+
"workflow approval/fill/copy nodes must declare at least one role or member assignee",
|
|
1328
|
+
normalized_args={
|
|
1329
|
+
"app_key": request.app_key,
|
|
1330
|
+
"mode": str(request.mode or "replace"),
|
|
1331
|
+
"preset": request.preset.value if request.preset else None,
|
|
1332
|
+
"nodes": nodes,
|
|
1333
|
+
"transitions": transitions,
|
|
1334
|
+
},
|
|
1335
|
+
details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
|
|
1336
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
|
|
1337
|
+
)
|
|
580
1338
|
if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
|
|
581
1339
|
return _failed(
|
|
582
1340
|
"FLOW_DEPENDENCY_MISSING",
|
|
@@ -644,8 +1402,16 @@ class AiBuilderFacade:
|
|
|
644
1402
|
}
|
|
645
1403
|
|
|
646
1404
|
def app_views_plan(self, *, profile: str, request: ViewsPlanRequest) -> JSONObject:
|
|
647
|
-
|
|
1405
|
+
fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
1406
|
+
if fields_result.get("status") == "failed":
|
|
1407
|
+
return fields_result
|
|
1408
|
+
current_fields = fields_result.get("fields", [])
|
|
648
1409
|
field_names = {str(field.get("name") or "") for field in current_fields}
|
|
1410
|
+
current_fields_by_name = {
|
|
1411
|
+
str(field.get("name") or ""): field
|
|
1412
|
+
for field in current_fields
|
|
1413
|
+
if isinstance(field, dict) and str(field.get("name") or "")
|
|
1414
|
+
}
|
|
649
1415
|
upsert_views = [view.model_dump(mode="json") for view in request.upsert_views]
|
|
650
1416
|
if request.preset is not None:
|
|
651
1417
|
upsert_views = _build_views_preset(request.preset, list(field_names))
|
|
@@ -658,6 +1424,31 @@ class AiBuilderFacade:
|
|
|
658
1424
|
group_by = patch.get("group_by")
|
|
659
1425
|
if group_by and group_by not in field_names:
|
|
660
1426
|
blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": [group_by]})
|
|
1427
|
+
start_field = str(patch.get("start_field") or "").strip()
|
|
1428
|
+
end_field = str(patch.get("end_field") or "").strip()
|
|
1429
|
+
title_field = str(patch.get("title_field") or "").strip()
|
|
1430
|
+
if patch.get("type") == "gantt":
|
|
1431
|
+
missing_required = []
|
|
1432
|
+
if not start_field:
|
|
1433
|
+
missing_required.append("start_field")
|
|
1434
|
+
if not end_field:
|
|
1435
|
+
missing_required.append("end_field")
|
|
1436
|
+
if missing_required:
|
|
1437
|
+
blocking_issues.append({"error_code": "INVALID_GANTT_CONFIG", "view_name": patch.get("name"), "missing_fields": missing_required})
|
|
1438
|
+
missing_gantt_fields = [name for name in (start_field, end_field, title_field) if name and name not in field_names]
|
|
1439
|
+
if missing_gantt_fields:
|
|
1440
|
+
blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_gantt_fields})
|
|
1441
|
+
translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.get("filters") or [])
|
|
1442
|
+
if filter_issues:
|
|
1443
|
+
blocking_issues.extend(
|
|
1444
|
+
{
|
|
1445
|
+
**issue,
|
|
1446
|
+
"view_name": patch.get("name"),
|
|
1447
|
+
}
|
|
1448
|
+
for issue in filter_issues
|
|
1449
|
+
)
|
|
1450
|
+
if translated_filters:
|
|
1451
|
+
patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
|
|
661
1452
|
normalized_args = {
|
|
662
1453
|
"app_key": request.app_key,
|
|
663
1454
|
"upsert_views": upsert_views,
|
|
@@ -670,7 +1461,11 @@ class AiBuilderFacade:
|
|
|
670
1461
|
"message": "view plan has blocking issues" if blocking_issues else "planned view patch",
|
|
671
1462
|
"normalized_args": normalized_args,
|
|
672
1463
|
"missing_fields": [],
|
|
673
|
-
"allowed_values": {
|
|
1464
|
+
"allowed_values": {
|
|
1465
|
+
"view_types": [member.value for member in PublicViewType],
|
|
1466
|
+
"presets": [preset.value for preset in ViewsPreset],
|
|
1467
|
+
"view.filter.operator": [member.value for member in ViewFilterOperator],
|
|
1468
|
+
},
|
|
674
1469
|
"details": {},
|
|
675
1470
|
"request_id": None,
|
|
676
1471
|
"views_diff_preview": {
|
|
@@ -728,8 +1523,9 @@ class AiBuilderFacade:
|
|
|
728
1523
|
profile: str,
|
|
729
1524
|
app_key: str = "",
|
|
730
1525
|
package_tag_id: int | None = None,
|
|
731
|
-
|
|
1526
|
+
app_name: str = "",
|
|
732
1527
|
create_if_missing: bool = False,
|
|
1528
|
+
publish: bool = True,
|
|
733
1529
|
add_fields: list[FieldPatch],
|
|
734
1530
|
update_fields: list[FieldUpdatePatch],
|
|
735
1531
|
remove_fields: list[FieldRemovePatch],
|
|
@@ -737,8 +1533,9 @@ class AiBuilderFacade:
|
|
|
737
1533
|
normalized_args = {
|
|
738
1534
|
"app_key": app_key,
|
|
739
1535
|
"package_tag_id": package_tag_id,
|
|
740
|
-
"
|
|
1536
|
+
"app_name": app_name,
|
|
741
1537
|
"create_if_missing": create_if_missing,
|
|
1538
|
+
"publish": publish,
|
|
742
1539
|
"add_fields": [patch.model_dump(mode="json") for patch in add_fields],
|
|
743
1540
|
"update_fields": [patch.model_dump(mode="json") for patch in update_fields],
|
|
744
1541
|
"remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
|
|
@@ -746,27 +1543,36 @@ class AiBuilderFacade:
|
|
|
746
1543
|
resolved = self._resolve_or_create_target_app(
|
|
747
1544
|
profile=profile,
|
|
748
1545
|
app_key=app_key,
|
|
749
|
-
|
|
1546
|
+
app_name=app_name,
|
|
750
1547
|
package_tag_id=package_tag_id,
|
|
751
1548
|
create_if_missing=create_if_missing,
|
|
752
1549
|
)
|
|
753
1550
|
if resolved.get("status") == "failed":
|
|
1551
|
+
if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
|
|
1552
|
+
resolved["normalized_args"] = normalized_args
|
|
754
1553
|
return resolved
|
|
755
1554
|
target = ResolvedApp(
|
|
756
1555
|
app_key=str(resolved["app_key"]),
|
|
757
1556
|
app_name=str(resolved["app_name"]),
|
|
758
1557
|
tag_ids=_coerce_int_list(resolved.get("tag_ids")),
|
|
759
1558
|
)
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
app_key=target.app_key
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1559
|
+
schema_readback_delayed = False
|
|
1560
|
+
try:
|
|
1561
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
|
|
1562
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1563
|
+
api_error = _coerce_api_error(error)
|
|
1564
|
+
if not bool(resolved.get("created")) or api_error.http_status != 404:
|
|
1565
|
+
return _failed_from_api_error(
|
|
1566
|
+
"SCHEMA_READBACK_FAILED",
|
|
1567
|
+
api_error,
|
|
1568
|
+
normalized_args=normalized_args,
|
|
1569
|
+
allowed_values={"field_types": [item.value for item in PublicFieldType]},
|
|
1570
|
+
details={"app_key": target.app_key},
|
|
1571
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
1572
|
+
)
|
|
1573
|
+
schema_result = _empty_schema_result(target.app_name)
|
|
1574
|
+
_schema_source = "synthetic_new_app"
|
|
1575
|
+
schema_readback_delayed = True
|
|
770
1576
|
parsed = _parse_schema(schema_result)
|
|
771
1577
|
current_fields = parsed["fields"]
|
|
772
1578
|
layout = parsed["layout"]
|
|
@@ -831,7 +1637,7 @@ class AiBuilderFacade:
|
|
|
831
1637
|
if not added and not updated and not removed and not bool(resolved.get("created")):
|
|
832
1638
|
tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
|
|
833
1639
|
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
834
|
-
|
|
1640
|
+
response = {
|
|
835
1641
|
"status": "success",
|
|
836
1642
|
"error_code": None,
|
|
837
1643
|
"recoverable": False,
|
|
@@ -851,6 +1657,7 @@ class AiBuilderFacade:
|
|
|
851
1657
|
"tag_ids_after": tag_ids_after,
|
|
852
1658
|
"package_attached": package_attached,
|
|
853
1659
|
}
|
|
1660
|
+
return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
|
|
854
1661
|
|
|
855
1662
|
payload = _build_form_payload_from_fields(
|
|
856
1663
|
title=schema_result.get("formTitle") or target.app_name,
|
|
@@ -858,6 +1665,11 @@ class AiBuilderFacade:
|
|
|
858
1665
|
fields=current_fields,
|
|
859
1666
|
layout=layout,
|
|
860
1667
|
)
|
|
1668
|
+
payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1669
|
+
profile=profile,
|
|
1670
|
+
app_key=target.app_key,
|
|
1671
|
+
current_schema=schema_result,
|
|
1672
|
+
)
|
|
861
1673
|
try:
|
|
862
1674
|
self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
|
|
863
1675
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -873,13 +1685,8 @@ class AiBuilderFacade:
|
|
|
873
1685
|
},
|
|
874
1686
|
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
875
1687
|
)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
|
|
879
|
-
verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
|
|
880
|
-
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
881
|
-
return {
|
|
882
|
-
"status": "success" if verification_ok and (package_attached is not False) else "partial_success",
|
|
1688
|
+
response = {
|
|
1689
|
+
"status": "success",
|
|
883
1690
|
"error_code": None,
|
|
884
1691
|
"recoverable": False,
|
|
885
1692
|
"message": "applied schema patch",
|
|
@@ -888,21 +1695,11 @@ class AiBuilderFacade:
|
|
|
888
1695
|
"allowed_values": {"field_types": [item.value for item in PublicFieldType]},
|
|
889
1696
|
"details": {},
|
|
890
1697
|
"request_id": None,
|
|
891
|
-
"suggested_next_call": None
|
|
892
|
-
if package_attached is not False
|
|
893
|
-
else {
|
|
894
|
-
"tool_name": "package_attach_app",
|
|
895
|
-
"arguments": {
|
|
896
|
-
"profile": profile,
|
|
897
|
-
"tag_id": package_tag_id,
|
|
898
|
-
"app_key": target.app_key,
|
|
899
|
-
"app_title": app_title or target.app_name,
|
|
900
|
-
},
|
|
901
|
-
},
|
|
1698
|
+
"suggested_next_call": None,
|
|
902
1699
|
"noop": False,
|
|
903
1700
|
"verification": {
|
|
904
|
-
"fields_verified":
|
|
905
|
-
"package_attached":
|
|
1701
|
+
"fields_verified": False,
|
|
1702
|
+
"package_attached": None,
|
|
906
1703
|
},
|
|
907
1704
|
"app_key": target.app_key,
|
|
908
1705
|
"created": bool(resolved.get("created")),
|
|
@@ -911,10 +1708,71 @@ class AiBuilderFacade:
|
|
|
911
1708
|
"updated": updated,
|
|
912
1709
|
"removed": removed,
|
|
913
1710
|
},
|
|
914
|
-
"verified":
|
|
915
|
-
"tag_ids_after":
|
|
916
|
-
"package_attached":
|
|
1711
|
+
"verified": False,
|
|
1712
|
+
"tag_ids_after": [],
|
|
1713
|
+
"package_attached": None,
|
|
917
1714
|
}
|
|
1715
|
+
if schema_readback_delayed:
|
|
1716
|
+
response["verification"]["schema_readback_delayed"] = True
|
|
1717
|
+
response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
|
|
1718
|
+
verification_ok = False
|
|
1719
|
+
tag_ids_after: list[int] = []
|
|
1720
|
+
package_attached: bool | None = None
|
|
1721
|
+
verification_error: QingflowApiError | None = None
|
|
1722
|
+
try:
|
|
1723
|
+
verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
|
|
1724
|
+
verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
|
|
1725
|
+
verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
|
|
1726
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1727
|
+
verification_error = _coerce_api_error(error)
|
|
1728
|
+
verification_ok = False
|
|
1729
|
+
try:
|
|
1730
|
+
base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
|
|
1731
|
+
tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
|
|
1732
|
+
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
1733
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1734
|
+
base_error = _coerce_api_error(error)
|
|
1735
|
+
if verification_error is None:
|
|
1736
|
+
verification_error = base_error
|
|
1737
|
+
tag_ids_after = []
|
|
1738
|
+
package_attached = None if package_tag_id is None else False
|
|
1739
|
+
response["verification"]["fields_verified"] = verification_ok
|
|
1740
|
+
response["verification"]["package_attached"] = package_attached
|
|
1741
|
+
response["verified"] = verification_ok
|
|
1742
|
+
response["tag_ids_after"] = tag_ids_after
|
|
1743
|
+
response["package_attached"] = package_attached
|
|
1744
|
+
if package_attached is False:
|
|
1745
|
+
response["suggested_next_call"] = {
|
|
1746
|
+
"tool_name": "package_attach_app",
|
|
1747
|
+
"arguments": {
|
|
1748
|
+
"profile": profile,
|
|
1749
|
+
"tag_id": package_tag_id,
|
|
1750
|
+
"app_key": target.app_key,
|
|
1751
|
+
"app_title": app_name or target.app_name,
|
|
1752
|
+
},
|
|
1753
|
+
}
|
|
1754
|
+
publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
|
|
1755
|
+
if verification_ok and package_attached is not False and not publish_failed:
|
|
1756
|
+
response["status"] = "success"
|
|
1757
|
+
else:
|
|
1758
|
+
response["status"] = "partial_success"
|
|
1759
|
+
if verification_error is not None:
|
|
1760
|
+
response["recoverable"] = True
|
|
1761
|
+
response["error_code"] = response.get("error_code") or (
|
|
1762
|
+
"READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
|
|
1763
|
+
)
|
|
1764
|
+
response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
|
|
1765
|
+
response["request_id"] = response.get("request_id") or verification_error.request_id
|
|
1766
|
+
details = response.get("details")
|
|
1767
|
+
if not isinstance(details, dict):
|
|
1768
|
+
details = {}
|
|
1769
|
+
response["details"] = details
|
|
1770
|
+
details["verification_error"] = {
|
|
1771
|
+
"message": verification_error.message,
|
|
1772
|
+
"http_status": verification_error.http_status,
|
|
1773
|
+
"backend_code": verification_error.backend_code,
|
|
1774
|
+
}
|
|
1775
|
+
return response
|
|
918
1776
|
|
|
919
1777
|
def app_layout_apply(
|
|
920
1778
|
self,
|
|
@@ -923,22 +1781,25 @@ class AiBuilderFacade:
|
|
|
923
1781
|
app_key: str,
|
|
924
1782
|
mode: LayoutApplyMode = LayoutApplyMode.merge,
|
|
925
1783
|
sections: list[LayoutSectionPatch],
|
|
1784
|
+
publish: bool = True,
|
|
926
1785
|
) -> JSONObject:
|
|
927
1786
|
normalized_args = {
|
|
928
1787
|
"app_key": app_key,
|
|
929
1788
|
"mode": mode.value,
|
|
930
1789
|
"sections": [section.model_dump(mode="json") for section in sections],
|
|
1790
|
+
"publish": publish,
|
|
931
1791
|
}
|
|
932
|
-
|
|
933
|
-
profile=profile,
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1792
|
+
try:
|
|
1793
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
1794
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1795
|
+
api_error = _coerce_api_error(error)
|
|
1796
|
+
return _failed_from_api_error(
|
|
1797
|
+
"LAYOUT_READ_FAILED",
|
|
1798
|
+
api_error,
|
|
1799
|
+
normalized_args=normalized_args,
|
|
1800
|
+
details={"app_key": app_key},
|
|
1801
|
+
suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1802
|
+
)
|
|
942
1803
|
parsed = _parse_schema(schema_result)
|
|
943
1804
|
current_fields = parsed["fields"]
|
|
944
1805
|
fields_by_name = {field["name"]: field for field in current_fields}
|
|
@@ -996,7 +1857,7 @@ class AiBuilderFacade:
|
|
|
996
1857
|
else merged["layout"]
|
|
997
1858
|
)
|
|
998
1859
|
if _layouts_equal(parsed["layout"], target_layout):
|
|
999
|
-
|
|
1860
|
+
response = {
|
|
1000
1861
|
"status": "success",
|
|
1001
1862
|
"error_code": None,
|
|
1002
1863
|
"recoverable": False,
|
|
@@ -1019,10 +1880,16 @@ class AiBuilderFacade:
|
|
|
1019
1880
|
},
|
|
1020
1881
|
"verified": True,
|
|
1021
1882
|
}
|
|
1883
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1022
1884
|
payload = _build_form_payload_from_existing_schema(
|
|
1023
1885
|
current_schema=schema_result,
|
|
1024
1886
|
layout=target_layout,
|
|
1025
1887
|
)
|
|
1888
|
+
payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1889
|
+
profile=profile,
|
|
1890
|
+
app_key=app_key,
|
|
1891
|
+
current_schema=schema_result,
|
|
1892
|
+
)
|
|
1026
1893
|
applied_layout = target_layout
|
|
1027
1894
|
fallback_applied = None
|
|
1028
1895
|
try:
|
|
@@ -1035,6 +1902,11 @@ class AiBuilderFacade:
|
|
|
1035
1902
|
current_schema=schema_result,
|
|
1036
1903
|
layout=flattened_layout,
|
|
1037
1904
|
)
|
|
1905
|
+
fallback_payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1906
|
+
profile=profile,
|
|
1907
|
+
app_key=app_key,
|
|
1908
|
+
current_schema=schema_result,
|
|
1909
|
+
)
|
|
1038
1910
|
try:
|
|
1039
1911
|
self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
|
|
1040
1912
|
applied_layout = flattened_layout
|
|
@@ -1068,8 +1940,33 @@ class AiBuilderFacade:
|
|
|
1068
1940
|
},
|
|
1069
1941
|
suggested_next_call={"tool_name": "app_layout_plan", "arguments": {"profile": profile, **normalized_args}},
|
|
1070
1942
|
)
|
|
1071
|
-
verified = self.
|
|
1072
|
-
|
|
1943
|
+
verified = self.app_read_layout_summary(profile=profile, app_key=app_key)
|
|
1944
|
+
if verified.get("status") == "failed":
|
|
1945
|
+
response = {
|
|
1946
|
+
"status": "partial_success",
|
|
1947
|
+
"error_code": "LAYOUT_READBACK_PENDING",
|
|
1948
|
+
"recoverable": True,
|
|
1949
|
+
"message": "applied app layout; layout readback pending",
|
|
1950
|
+
"normalized_args": normalized_args,
|
|
1951
|
+
"missing_fields": [],
|
|
1952
|
+
"allowed_values": {"modes": ["merge", "replace"]},
|
|
1953
|
+
"details": {},
|
|
1954
|
+
"request_id": verified.get("request_id"),
|
|
1955
|
+
"suggested_next_call": {"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1956
|
+
"noop": False,
|
|
1957
|
+
"verification": {"layout_verified": False, "layout_read_unavailable": True},
|
|
1958
|
+
"app_key": app_key,
|
|
1959
|
+
"layout_diff": {
|
|
1960
|
+
"mode": mode.value,
|
|
1961
|
+
"replaced": mode == LayoutApplyMode.replace,
|
|
1962
|
+
"merged": mode == LayoutApplyMode.merge,
|
|
1963
|
+
"auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
|
|
1964
|
+
"fallback_applied": fallback_applied,
|
|
1965
|
+
},
|
|
1966
|
+
"verified": False,
|
|
1967
|
+
}
|
|
1968
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1969
|
+
response = {
|
|
1073
1970
|
"status": "partial_success" if fallback_applied else "success",
|
|
1074
1971
|
"error_code": None,
|
|
1075
1972
|
"recoverable": False,
|
|
@@ -1081,7 +1978,7 @@ class AiBuilderFacade:
|
|
|
1081
1978
|
"request_id": None,
|
|
1082
1979
|
"suggested_next_call": None,
|
|
1083
1980
|
"noop": False,
|
|
1084
|
-
"verification": {"layout_verified": verified["
|
|
1981
|
+
"verification": {"layout_verified": verified["sections"] == applied_layout.get("sections", [])},
|
|
1085
1982
|
"app_key": app_key,
|
|
1086
1983
|
"layout_diff": {
|
|
1087
1984
|
"mode": mode.value,
|
|
@@ -1090,8 +1987,9 @@ class AiBuilderFacade:
|
|
|
1090
1987
|
"auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
|
|
1091
1988
|
"fallback_applied": fallback_applied,
|
|
1092
1989
|
},
|
|
1093
|
-
"verified": verified["
|
|
1990
|
+
"verified": verified["sections"] == applied_layout.get("sections", []),
|
|
1094
1991
|
}
|
|
1992
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1095
1993
|
|
|
1096
1994
|
def app_flow_apply(
|
|
1097
1995
|
self,
|
|
@@ -1101,12 +1999,14 @@ class AiBuilderFacade:
|
|
|
1101
1999
|
nodes: list[dict[str, Any]],
|
|
1102
2000
|
transitions: list[dict[str, Any]],
|
|
1103
2001
|
mode: str = "replace",
|
|
2002
|
+
publish: bool = True,
|
|
1104
2003
|
) -> JSONObject:
|
|
1105
2004
|
normalized_args = {
|
|
1106
2005
|
"app_key": app_key,
|
|
1107
2006
|
"mode": mode,
|
|
1108
2007
|
"nodes": nodes,
|
|
1109
2008
|
"transitions": transitions,
|
|
2009
|
+
"publish": publish,
|
|
1110
2010
|
}
|
|
1111
2011
|
if mode != "replace":
|
|
1112
2012
|
return _failed(
|
|
@@ -1116,17 +2016,54 @@ class AiBuilderFacade:
|
|
|
1116
2016
|
allowed_values={"modes": ["replace"]},
|
|
1117
2017
|
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}},
|
|
1118
2018
|
)
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
profile=profile,
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2019
|
+
try:
|
|
2020
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2021
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
2022
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2023
|
+
api_error = _coerce_api_error(error)
|
|
2024
|
+
return _failed_from_api_error(
|
|
2025
|
+
"FLOW_READ_FAILED",
|
|
2026
|
+
api_error,
|
|
2027
|
+
normalized_args=normalized_args,
|
|
2028
|
+
details={"app_key": app_key},
|
|
2029
|
+
suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2030
|
+
)
|
|
1129
2031
|
entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
|
|
2032
|
+
current_fields = _parse_schema(schema)["fields"]
|
|
2033
|
+
nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
|
|
2034
|
+
if resolution_issues:
|
|
2035
|
+
first_issue = resolution_issues[0]
|
|
2036
|
+
suggested_call = None
|
|
2037
|
+
if first_issue.get("kind", "").startswith("role"):
|
|
2038
|
+
suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
|
|
2039
|
+
elif first_issue.get("kind", "").startswith("member"):
|
|
2040
|
+
suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
|
|
2041
|
+
elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
|
|
2042
|
+
suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}}
|
|
2043
|
+
return _failed(
|
|
2044
|
+
first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
|
|
2045
|
+
"workflow contains unresolved assignees or field permissions",
|
|
2046
|
+
normalized_args=normalized_args,
|
|
2047
|
+
details={"issues": resolution_issues},
|
|
2048
|
+
suggested_next_call=suggested_call,
|
|
2049
|
+
)
|
|
2050
|
+
assignee_required_nodes = [
|
|
2051
|
+
node.get("id")
|
|
2052
|
+
for node in nodes
|
|
2053
|
+
if str(node.get("type") or "") in {"approve", "fill", "copy"}
|
|
2054
|
+
and not (
|
|
2055
|
+
(node.get("assignees") or {}).get("role_entries")
|
|
2056
|
+
or (node.get("assignees") or {}).get("member_uids")
|
|
2057
|
+
)
|
|
2058
|
+
]
|
|
2059
|
+
if assignee_required_nodes:
|
|
2060
|
+
return _failed(
|
|
2061
|
+
"FLOW_ASSIGNEE_REQUIRED",
|
|
2062
|
+
"workflow approval/fill/copy nodes must declare at least one role or member assignee",
|
|
2063
|
+
normalized_args=normalized_args,
|
|
2064
|
+
details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
|
|
2065
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
|
|
2066
|
+
)
|
|
1130
2067
|
workflow_spec = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
|
|
1131
2068
|
if workflow_spec.get("status") == "failed":
|
|
1132
2069
|
workflow_spec["normalized_args"] = normalized_args
|
|
@@ -1134,7 +2071,8 @@ class AiBuilderFacade:
|
|
|
1134
2071
|
workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
|
|
1135
2072
|
return workflow_spec
|
|
1136
2073
|
desired_node_count = len([node for node in nodes if node.get("type") != "end"])
|
|
1137
|
-
|
|
2074
|
+
current_workflow, _workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2075
|
+
current_node_count = len(_summarize_workflow_nodes(current_workflow))
|
|
1138
2076
|
if current_node_count == desired_node_count and desired_node_count > 0:
|
|
1139
2077
|
# Lightweight idempotency check for repeat submissions of same simple graph.
|
|
1140
2078
|
pass
|
|
@@ -1146,42 +2084,57 @@ class AiBuilderFacade:
|
|
|
1146
2084
|
os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
|
|
1147
2085
|
try:
|
|
1148
2086
|
assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
|
|
2087
|
+
manifest = default_manifest()
|
|
2088
|
+
manifest["solution_name"] = base.get("formTitle") or app_key
|
|
2089
|
+
manifest["preferences"]["create_package"] = False
|
|
2090
|
+
manifest["preferences"]["create_portal"] = False
|
|
2091
|
+
manifest["preferences"]["create_navigation"] = False
|
|
2092
|
+
manifest["entities"] = [entity]
|
|
2093
|
+
assembly.set_manifest(manifest)
|
|
2094
|
+
artifacts = default_artifacts()
|
|
2095
|
+
artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
|
|
2096
|
+
assembly.set_artifacts(artifacts)
|
|
2097
|
+
flow_stage_spec = {
|
|
2098
|
+
"solution_name": manifest["solution_name"],
|
|
2099
|
+
"entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
|
|
2100
|
+
}
|
|
2101
|
+
assembly.set_stage_spec("app_flow", flow_stage_spec)
|
|
2102
|
+
stage = self.solutions.solution_build_flow(
|
|
2103
|
+
profile=profile,
|
|
2104
|
+
mode="apply",
|
|
2105
|
+
build_id=build_id,
|
|
2106
|
+
flow_spec=flow_stage_spec,
|
|
2107
|
+
publish=False,
|
|
2108
|
+
run_label=None,
|
|
2109
|
+
repair_patch={},
|
|
2110
|
+
)
|
|
1149
2111
|
finally:
|
|
1150
2112
|
if previous_build_home is None:
|
|
1151
2113
|
os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
|
|
1152
|
-
manifest = default_manifest()
|
|
1153
|
-
manifest["solution_name"] = base.get("formTitle") or app_key
|
|
1154
|
-
manifest["preferences"]["create_package"] = False
|
|
1155
|
-
manifest["preferences"]["create_portal"] = False
|
|
1156
|
-
manifest["preferences"]["create_navigation"] = False
|
|
1157
|
-
manifest["entities"] = [entity]
|
|
1158
|
-
assembly.set_manifest(manifest)
|
|
1159
|
-
artifacts = default_artifacts()
|
|
1160
|
-
artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
|
|
1161
|
-
assembly.set_artifacts(artifacts)
|
|
1162
|
-
stage = self.solutions.solution_build_flow(
|
|
1163
|
-
profile=profile,
|
|
1164
|
-
mode="apply",
|
|
1165
|
-
build_id=build_id,
|
|
1166
|
-
flow_spec={
|
|
1167
|
-
"solution_name": manifest["solution_name"],
|
|
1168
|
-
"entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
|
|
1169
|
-
},
|
|
1170
|
-
publish=False,
|
|
1171
|
-
run_label=None,
|
|
1172
|
-
repair_patch={},
|
|
1173
|
-
)
|
|
1174
2114
|
if stage.get("status") != "success":
|
|
1175
2115
|
failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
|
|
1176
2116
|
failed["normalized_args"] = normalized_args
|
|
1177
|
-
|
|
2117
|
+
suggested_next_call = failed.get("suggested_next_call")
|
|
2118
|
+
if not isinstance(suggested_next_call, dict):
|
|
2119
|
+
suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
|
|
2120
|
+
elif suggested_next_call.get("tool_name") == "app_flow_plan":
|
|
2121
|
+
arguments = suggested_next_call.get("arguments")
|
|
2122
|
+
if not isinstance(arguments, dict):
|
|
2123
|
+
arguments = {}
|
|
2124
|
+
arguments.setdefault("profile", profile)
|
|
2125
|
+
arguments.setdefault("app_key", app_key)
|
|
2126
|
+
arguments.setdefault("mode", mode)
|
|
2127
|
+
arguments.setdefault("nodes", nodes)
|
|
2128
|
+
arguments.setdefault("transitions", transitions)
|
|
2129
|
+
suggested_next_call["arguments"] = arguments
|
|
2130
|
+
failed["suggested_next_call"] = suggested_next_call
|
|
1178
2131
|
return failed
|
|
1179
|
-
verified_nodes = self.
|
|
1180
|
-
|
|
1181
|
-
"status": "success",
|
|
2132
|
+
verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2133
|
+
response = {
|
|
2134
|
+
"status": "success" if bool(verified_nodes) or not verified_nodes_unavailable else "partial_success",
|
|
1182
2135
|
"error_code": None,
|
|
1183
|
-
"recoverable":
|
|
1184
|
-
"message": "applied workflow patch",
|
|
2136
|
+
"recoverable": bool(verified_nodes_unavailable),
|
|
2137
|
+
"message": "applied workflow patch" if not verified_nodes_unavailable else "applied workflow patch; flow readback pending",
|
|
1185
2138
|
"normalized_args": normalized_args,
|
|
1186
2139
|
"missing_fields": [],
|
|
1187
2140
|
"allowed_values": {"modes": ["replace"]},
|
|
@@ -1189,11 +2142,15 @@ class AiBuilderFacade:
|
|
|
1189
2142
|
"request_id": None,
|
|
1190
2143
|
"suggested_next_call": None,
|
|
1191
2144
|
"noop": False,
|
|
1192
|
-
"verification": {"workflow_verified": bool(verified_nodes)},
|
|
2145
|
+
"verification": {"workflow_verified": bool(verified_nodes), "workflow_read_unavailable": verified_nodes_unavailable},
|
|
1193
2146
|
"app_key": app_key,
|
|
1194
2147
|
"flow_diff": {"mode": "replace", "node_count": desired_node_count},
|
|
1195
2148
|
"verified": bool(verified_nodes),
|
|
1196
2149
|
}
|
|
2150
|
+
if verified_nodes_unavailable:
|
|
2151
|
+
response["error_code"] = "FLOW_READBACK_PENDING"
|
|
2152
|
+
response["suggested_next_call"] = {"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}}
|
|
2153
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1197
2154
|
|
|
1198
2155
|
def app_views_apply(
|
|
1199
2156
|
self,
|
|
@@ -1202,21 +2159,23 @@ class AiBuilderFacade:
|
|
|
1202
2159
|
app_key: str,
|
|
1203
2160
|
upsert_views: list[ViewUpsertPatch],
|
|
1204
2161
|
remove_views: list[str],
|
|
2162
|
+
publish: bool = True,
|
|
1205
2163
|
) -> JSONObject:
|
|
1206
2164
|
normalized_args = {
|
|
1207
2165
|
"app_key": app_key,
|
|
1208
2166
|
"upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
|
|
1209
2167
|
"remove_views": list(remove_views),
|
|
2168
|
+
"publish": publish,
|
|
1210
2169
|
}
|
|
1211
2170
|
if not upsert_views and not remove_views:
|
|
1212
|
-
|
|
2171
|
+
response = {
|
|
1213
2172
|
"status": "success",
|
|
1214
2173
|
"error_code": None,
|
|
1215
2174
|
"recoverable": False,
|
|
1216
2175
|
"message": "no view changes requested",
|
|
1217
2176
|
"normalized_args": normalized_args,
|
|
1218
2177
|
"missing_fields": [],
|
|
1219
|
-
"allowed_values": {"view_types": [
|
|
2178
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
1220
2179
|
"details": {},
|
|
1221
2180
|
"request_id": None,
|
|
1222
2181
|
"suggested_next_call": None,
|
|
@@ -1226,17 +2185,21 @@ class AiBuilderFacade:
|
|
|
1226
2185
|
"views_diff": {"created": [], "updated": [], "removed": []},
|
|
1227
2186
|
"verified": True,
|
|
1228
2187
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
profile=profile,
|
|
1232
|
-
app_key=app_key
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
2188
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
2189
|
+
try:
|
|
2190
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2191
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
2192
|
+
existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
|
|
2193
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2194
|
+
api_error = _coerce_api_error(error)
|
|
2195
|
+
return _failed_from_api_error(
|
|
2196
|
+
"VIEWS_READ_FAILED",
|
|
2197
|
+
api_error,
|
|
2198
|
+
normalized_args=normalized_args,
|
|
2199
|
+
details={"app_key": app_key},
|
|
2200
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2201
|
+
)
|
|
2202
|
+
existing_views = existing_views or []
|
|
1240
2203
|
existing_by_name = {}
|
|
1241
2204
|
for view in existing_views if isinstance(existing_views, list) else []:
|
|
1242
2205
|
if not isinstance(view, dict):
|
|
@@ -1247,6 +2210,11 @@ class AiBuilderFacade:
|
|
|
1247
2210
|
existing_by_name[name] = key
|
|
1248
2211
|
parsed_schema = _parse_schema(schema)
|
|
1249
2212
|
field_names = {field["name"] for field in parsed_schema["fields"]}
|
|
2213
|
+
current_fields_by_name = {
|
|
2214
|
+
str(field.get("name") or ""): field
|
|
2215
|
+
for field in parsed_schema["fields"]
|
|
2216
|
+
if isinstance(field, dict) and str(field.get("name") or "")
|
|
2217
|
+
}
|
|
1250
2218
|
removed: list[str] = []
|
|
1251
2219
|
for name in remove_views:
|
|
1252
2220
|
key = existing_by_name.get(name)
|
|
@@ -1290,6 +2258,35 @@ class AiBuilderFacade:
|
|
|
1290
2258
|
missing_fields=[patch.group_by],
|
|
1291
2259
|
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1292
2260
|
)
|
|
2261
|
+
for gantt_field_name in (patch.start_field, patch.end_field, patch.title_field):
|
|
2262
|
+
if gantt_field_name and gantt_field_name not in field_names:
|
|
2263
|
+
return _failed(
|
|
2264
|
+
"UNKNOWN_VIEW_FIELD",
|
|
2265
|
+
f"gantt configuration references unknown field '{gantt_field_name}'",
|
|
2266
|
+
normalized_args=normalized_args,
|
|
2267
|
+
details={
|
|
2268
|
+
"app_key": app_key,
|
|
2269
|
+
"view_name": patch.name,
|
|
2270
|
+
"missing_fields": [gantt_field_name],
|
|
2271
|
+
},
|
|
2272
|
+
missing_fields=[gantt_field_name],
|
|
2273
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2274
|
+
)
|
|
2275
|
+
translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
|
|
2276
|
+
if filter_issues:
|
|
2277
|
+
first_issue = filter_issues[0]
|
|
2278
|
+
return _failed(
|
|
2279
|
+
str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
|
|
2280
|
+
"view filters reference unknown fields",
|
|
2281
|
+
normalized_args=normalized_args,
|
|
2282
|
+
details={
|
|
2283
|
+
"app_key": app_key,
|
|
2284
|
+
"view_name": patch.name,
|
|
2285
|
+
**first_issue,
|
|
2286
|
+
},
|
|
2287
|
+
missing_fields=list(first_issue.get("missing_fields") or []),
|
|
2288
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2289
|
+
)
|
|
1293
2290
|
existing_key = existing_by_name.get(patch.name)
|
|
1294
2291
|
created_key: str | None = None
|
|
1295
2292
|
try:
|
|
@@ -1300,6 +2297,7 @@ class AiBuilderFacade:
|
|
|
1300
2297
|
source_viewgraph_key=existing_key,
|
|
1301
2298
|
schema=schema,
|
|
1302
2299
|
patch=patch,
|
|
2300
|
+
view_filters=translated_filters,
|
|
1303
2301
|
)
|
|
1304
2302
|
self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
|
|
1305
2303
|
updated.append(patch.name)
|
|
@@ -1314,6 +2312,7 @@ class AiBuilderFacade:
|
|
|
1314
2312
|
source_viewgraph_key=created_key,
|
|
1315
2313
|
schema=schema,
|
|
1316
2314
|
patch=patch,
|
|
2315
|
+
view_filters=translated_filters,
|
|
1317
2316
|
)
|
|
1318
2317
|
self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
|
|
1319
2318
|
else:
|
|
@@ -1323,11 +2322,41 @@ class AiBuilderFacade:
|
|
|
1323
2322
|
schema=schema,
|
|
1324
2323
|
patch=patch,
|
|
1325
2324
|
ordinal=ordinal,
|
|
2325
|
+
view_filters=translated_filters,
|
|
1326
2326
|
)
|
|
1327
2327
|
self.views.view_create(profile=profile, payload=payload)
|
|
1328
2328
|
created.append(patch.name)
|
|
1329
2329
|
except (QingflowApiError, RuntimeError) as error:
|
|
1330
2330
|
api_error = _coerce_api_error(error)
|
|
2331
|
+
if api_error.backend_code == 48104:
|
|
2332
|
+
try:
|
|
2333
|
+
if existing_key or created_key:
|
|
2334
|
+
target_key = created_key or existing_key or ""
|
|
2335
|
+
fallback_payload = _build_minimal_view_payload(
|
|
2336
|
+
app_key=app_key,
|
|
2337
|
+
schema=schema,
|
|
2338
|
+
patch=patch,
|
|
2339
|
+
ordinal=ordinal,
|
|
2340
|
+
view_filters=translated_filters,
|
|
2341
|
+
)
|
|
2342
|
+
self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
|
|
2343
|
+
if existing_key:
|
|
2344
|
+
updated.append(patch.name)
|
|
2345
|
+
else:
|
|
2346
|
+
created.append(patch.name)
|
|
2347
|
+
continue
|
|
2348
|
+
fallback_payload = _build_minimal_view_payload(
|
|
2349
|
+
app_key=app_key,
|
|
2350
|
+
schema=schema,
|
|
2351
|
+
patch=patch,
|
|
2352
|
+
ordinal=ordinal,
|
|
2353
|
+
view_filters=translated_filters,
|
|
2354
|
+
)
|
|
2355
|
+
self.views.view_create(profile=profile, payload=fallback_payload)
|
|
2356
|
+
created.append(patch.name)
|
|
2357
|
+
continue
|
|
2358
|
+
except (QingflowApiError, RuntimeError) as fallback_error:
|
|
2359
|
+
api_error = _coerce_api_error(fallback_error)
|
|
1331
2360
|
if created_key:
|
|
1332
2361
|
try:
|
|
1333
2362
|
self.views.view_delete(profile=profile, viewgraph_key=created_key)
|
|
@@ -1343,32 +2372,53 @@ class AiBuilderFacade:
|
|
|
1343
2372
|
"view_type": patch.type.value,
|
|
1344
2373
|
"columns": patch.columns,
|
|
1345
2374
|
"group_by": patch.group_by,
|
|
2375
|
+
"filters": [item.model_dump(mode="json") for item in patch.filters],
|
|
2376
|
+
"start_field": patch.start_field,
|
|
2377
|
+
"end_field": patch.end_field,
|
|
2378
|
+
"title_field": patch.title_field,
|
|
2379
|
+
"operation": "update" if existing_key or created_key else "create",
|
|
1346
2380
|
},
|
|
1347
2381
|
suggested_next_call={
|
|
1348
2382
|
"tool_name": "app_views_plan",
|
|
1349
2383
|
"arguments": {"profile": profile, "app_key": app_key},
|
|
1350
2384
|
},
|
|
1351
2385
|
)
|
|
1352
|
-
|
|
1353
|
-
|
|
2386
|
+
try:
|
|
2387
|
+
verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2388
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2389
|
+
api_error = _coerce_api_error(error)
|
|
2390
|
+
return _failed_from_api_error(
|
|
2391
|
+
"VIEWS_READ_FAILED",
|
|
2392
|
+
api_error,
|
|
2393
|
+
normalized_args=normalized_args,
|
|
2394
|
+
details={"app_key": app_key},
|
|
2395
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2396
|
+
)
|
|
2397
|
+
verified_names = {
|
|
2398
|
+
item.get("viewgraphName") or item.get("viewName") or item.get("title")
|
|
2399
|
+
for item in (verified_view_result or [])
|
|
2400
|
+
if isinstance(item, dict)
|
|
2401
|
+
}
|
|
2402
|
+
verified = (not verified_views_unavailable) and all(name in verified_names for name in created + updated) and all(name not in verified_names for name in removed)
|
|
1354
2403
|
noop = not created and not updated and not removed
|
|
1355
|
-
|
|
2404
|
+
response = {
|
|
1356
2405
|
"status": "success" if verified else "partial_success",
|
|
1357
|
-
"error_code": None,
|
|
1358
|
-
"recoverable":
|
|
1359
|
-
"message": "applied view patch",
|
|
2406
|
+
"error_code": None if not verified_views_unavailable else "VIEWS_READBACK_PENDING",
|
|
2407
|
+
"recoverable": bool(verified_views_unavailable),
|
|
2408
|
+
"message": "applied view patch" if not verified_views_unavailable else "applied view patch; views readback pending",
|
|
1360
2409
|
"normalized_args": normalized_args,
|
|
1361
2410
|
"missing_fields": [],
|
|
1362
|
-
"allowed_values": {"view_types": [
|
|
2411
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
1363
2412
|
"details": {},
|
|
1364
2413
|
"request_id": None,
|
|
1365
|
-
"suggested_next_call": None,
|
|
2414
|
+
"suggested_next_call": None if not verified_views_unavailable else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1366
2415
|
"noop": noop,
|
|
1367
|
-
"verification": {"views_verified": verified},
|
|
2416
|
+
"verification": {"views_verified": verified, "views_read_unavailable": verified_views_unavailable},
|
|
1368
2417
|
"app_key": app_key,
|
|
1369
2418
|
"views_diff": {"created": created, "updated": updated, "removed": removed},
|
|
1370
2419
|
"verified": verified,
|
|
1371
2420
|
}
|
|
2421
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1372
2422
|
|
|
1373
2423
|
def app_publish_verify(
|
|
1374
2424
|
self,
|
|
@@ -1378,12 +2428,33 @@ class AiBuilderFacade:
|
|
|
1378
2428
|
expected_package_tag_id: int | None = None,
|
|
1379
2429
|
) -> JSONObject:
|
|
1380
2430
|
normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
|
|
1381
|
-
|
|
2431
|
+
try:
|
|
2432
|
+
base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2433
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2434
|
+
api_error = _coerce_api_error(error)
|
|
2435
|
+
return _failed_from_api_error(
|
|
2436
|
+
"APP_READ_FAILED",
|
|
2437
|
+
api_error,
|
|
2438
|
+
normalized_args=normalized_args,
|
|
2439
|
+
details={"app_key": app_key},
|
|
2440
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2441
|
+
)
|
|
1382
2442
|
tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
|
|
1383
2443
|
already_published = bool(base_before.get("appPublishStatus") in {1, 2})
|
|
1384
2444
|
package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
|
|
1385
|
-
|
|
1386
|
-
|
|
2445
|
+
try:
|
|
2446
|
+
views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2447
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2448
|
+
api_error = _coerce_api_error(error)
|
|
2449
|
+
return _failed_from_api_error(
|
|
2450
|
+
"VIEWS_READ_FAILED",
|
|
2451
|
+
api_error,
|
|
2452
|
+
normalized_args=normalized_args,
|
|
2453
|
+
details={"app_key": app_key},
|
|
2454
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2455
|
+
)
|
|
2456
|
+
views_before = views_before or []
|
|
2457
|
+
if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
|
|
1387
2458
|
return {
|
|
1388
2459
|
"status": "success",
|
|
1389
2460
|
"error_code": None,
|
|
@@ -1418,17 +2489,38 @@ class AiBuilderFacade:
|
|
|
1418
2489
|
details={"app_key": app_key, "edit_version_no": edit_version_no},
|
|
1419
2490
|
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1420
2491
|
)
|
|
1421
|
-
|
|
2492
|
+
try:
|
|
2493
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2494
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2495
|
+
api_error = _coerce_api_error(error)
|
|
2496
|
+
return _failed_from_api_error(
|
|
2497
|
+
"APP_READ_FAILED",
|
|
2498
|
+
api_error,
|
|
2499
|
+
normalized_args=normalized_args,
|
|
2500
|
+
details={"app_key": app_key},
|
|
2501
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2502
|
+
)
|
|
1422
2503
|
tag_ids_after = _coerce_int_list(base.get("tagIds"))
|
|
1423
2504
|
package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
|
|
1424
|
-
|
|
1425
|
-
|
|
2505
|
+
try:
|
|
2506
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2507
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2508
|
+
api_error = _coerce_api_error(error)
|
|
2509
|
+
return _failed_from_api_error(
|
|
2510
|
+
"VIEWS_READ_FAILED",
|
|
2511
|
+
api_error,
|
|
2512
|
+
normalized_args=normalized_args,
|
|
2513
|
+
details={"app_key": app_key},
|
|
2514
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2515
|
+
)
|
|
2516
|
+
views = views or []
|
|
2517
|
+
views_ok = isinstance(views, list) and not views_unavailable
|
|
1426
2518
|
verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
|
|
1427
2519
|
return {
|
|
1428
2520
|
"status": "success" if verified else "partial_success",
|
|
1429
|
-
"error_code": None,
|
|
1430
|
-
"recoverable":
|
|
1431
|
-
"message": "published and verified app",
|
|
2521
|
+
"error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
|
|
2522
|
+
"recoverable": bool(views_unavailable),
|
|
2523
|
+
"message": "published and verified app" if not views_unavailable else "published app; views readback pending",
|
|
1432
2524
|
"normalized_args": normalized_args,
|
|
1433
2525
|
"missing_fields": [],
|
|
1434
2526
|
"allowed_values": {},
|
|
@@ -1441,7 +2533,7 @@ class AiBuilderFacade:
|
|
|
1441
2533
|
"arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
|
|
1442
2534
|
},
|
|
1443
2535
|
"noop": False,
|
|
1444
|
-
"verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok},
|
|
2536
|
+
"verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
|
|
1445
2537
|
"app_key": app_key,
|
|
1446
2538
|
"published": bool(base.get("appPublishStatus") in {1, 2}),
|
|
1447
2539
|
"package_attached": package_attached,
|
|
@@ -1450,29 +2542,151 @@ class AiBuilderFacade:
|
|
|
1450
2542
|
"verified": verified,
|
|
1451
2543
|
}
|
|
1452
2544
|
|
|
1453
|
-
def
|
|
2545
|
+
def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
|
|
2546
|
+
normalized_args = {"app_key": app_key}
|
|
2547
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
2548
|
+
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
2549
|
+
try:
|
|
2550
|
+
self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2551
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2552
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2553
|
+
api_error = _coerce_api_error(error)
|
|
2554
|
+
return _failed_from_api_error(
|
|
2555
|
+
"PUBLISH_FAILED",
|
|
2556
|
+
api_error,
|
|
2557
|
+
normalized_args=normalized_args,
|
|
2558
|
+
details={"app_key": app_key, "edit_version_no": edit_version_no},
|
|
2559
|
+
suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2560
|
+
)
|
|
2561
|
+
return {
|
|
2562
|
+
"status": "success",
|
|
2563
|
+
"error_code": None,
|
|
2564
|
+
"recoverable": False,
|
|
2565
|
+
"message": "published current app draft",
|
|
2566
|
+
"normalized_args": normalized_args,
|
|
2567
|
+
"missing_fields": [],
|
|
2568
|
+
"allowed_values": {},
|
|
2569
|
+
"details": {"app_key": app_key, "edit_version_no": edit_version_no},
|
|
2570
|
+
"request_id": None,
|
|
2571
|
+
"suggested_next_call": None,
|
|
2572
|
+
"noop": False,
|
|
2573
|
+
"verification": {"published": True},
|
|
2574
|
+
"app_key": app_key,
|
|
2575
|
+
"published": True,
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
|
|
2579
|
+
try:
|
|
2580
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
2581
|
+
except (QingflowApiError, RuntimeError):
|
|
2582
|
+
version_result = {}
|
|
2583
|
+
return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
|
|
2584
|
+
|
|
2585
|
+
def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
|
|
2586
|
+
response["publish_requested"] = publish
|
|
2587
|
+
if not publish:
|
|
2588
|
+
response["published"] = False
|
|
2589
|
+
return response
|
|
2590
|
+
publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key)
|
|
2591
|
+
response["publish_result"] = publish_result
|
|
2592
|
+
response["published"] = bool(publish_result.get("published"))
|
|
2593
|
+
verification = response.get("verification")
|
|
2594
|
+
if not isinstance(verification, dict):
|
|
2595
|
+
verification = {}
|
|
2596
|
+
response["verification"] = verification
|
|
2597
|
+
verification["published"] = bool(publish_result.get("published"))
|
|
2598
|
+
if publish_result.get("status") == "failed":
|
|
2599
|
+
response["status"] = "partial_success"
|
|
2600
|
+
response["error_code"] = response.get("error_code") or publish_result.get("error_code")
|
|
2601
|
+
response["recoverable"] = True
|
|
2602
|
+
response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
|
|
2603
|
+
if not response.get("suggested_next_call"):
|
|
2604
|
+
response["suggested_next_call"] = publish_result.get("suggested_next_call")
|
|
2605
|
+
return response
|
|
2606
|
+
|
|
2607
|
+
def _load_base_schema_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
|
|
1454
2608
|
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
1455
|
-
|
|
1456
|
-
profile=profile,
|
|
1457
|
-
app_key=app_key,
|
|
1458
|
-
form_type=1,
|
|
1459
|
-
being_draft=True,
|
|
1460
|
-
being_apply=None,
|
|
1461
|
-
audit_node_id=None,
|
|
1462
|
-
include_raw=True,
|
|
1463
|
-
)
|
|
1464
|
-
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
1465
|
-
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
2609
|
+
schema_result, schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
1466
2610
|
base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
1467
|
-
schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
|
|
1468
2611
|
return {
|
|
1469
2612
|
"base": base_result,
|
|
1470
2613
|
"schema": schema_result,
|
|
1471
2614
|
"parsed": _parse_schema(schema_result),
|
|
1472
|
-
"
|
|
1473
|
-
"workflow": workflow.get("result"),
|
|
2615
|
+
"schema_source": schema_source,
|
|
1474
2616
|
}
|
|
1475
2617
|
|
|
2618
|
+
def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
|
|
2619
|
+
try:
|
|
2620
|
+
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
2621
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2622
|
+
api_error = _coerce_api_error(error)
|
|
2623
|
+
if api_error.http_status == 404:
|
|
2624
|
+
try:
|
|
2625
|
+
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
2626
|
+
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
2627
|
+
legacy_api_error = _coerce_api_error(legacy_error)
|
|
2628
|
+
if tolerate_404 and legacy_api_error.http_status == 404:
|
|
2629
|
+
return [], True
|
|
2630
|
+
raise
|
|
2631
|
+
legacy_result = legacy_views.get("result")
|
|
2632
|
+
if _is_view_collection_shape(legacy_result):
|
|
2633
|
+
return _normalize_view_collection(legacy_result), False
|
|
2634
|
+
if tolerate_404:
|
|
2635
|
+
return [], True
|
|
2636
|
+
raise error
|
|
2637
|
+
raise
|
|
2638
|
+
return _normalize_view_collection(views.get("result")), False
|
|
2639
|
+
|
|
2640
|
+
def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
|
|
2641
|
+
try:
|
|
2642
|
+
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
2643
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2644
|
+
api_error = _coerce_api_error(error)
|
|
2645
|
+
if tolerate_404 and api_error.http_status == 404:
|
|
2646
|
+
return [], True
|
|
2647
|
+
raise
|
|
2648
|
+
return workflow.get("result"), False
|
|
2649
|
+
|
|
2650
|
+
def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
|
|
2651
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
2652
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2653
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2654
|
+
state["views"] = views
|
|
2655
|
+
state["workflow"] = workflow
|
|
2656
|
+
state["views_unavailable"] = views_unavailable
|
|
2657
|
+
state["workflow_unavailable"] = workflow_unavailable
|
|
2658
|
+
return state
|
|
2659
|
+
|
|
2660
|
+
def _read_schema_with_fallback(self, *, profile: str, app_key: str) -> tuple[dict[str, Any], str]:
|
|
2661
|
+
attempts = (
|
|
2662
|
+
("draft", True),
|
|
2663
|
+
("current", None),
|
|
2664
|
+
("published", False),
|
|
2665
|
+
)
|
|
2666
|
+
last_error: Exception | None = None
|
|
2667
|
+
for label, being_draft in attempts:
|
|
2668
|
+
try:
|
|
2669
|
+
schema = self.apps.app_get_form_schema(
|
|
2670
|
+
profile=profile,
|
|
2671
|
+
app_key=app_key,
|
|
2672
|
+
form_type=1,
|
|
2673
|
+
being_draft=being_draft,
|
|
2674
|
+
being_apply=None,
|
|
2675
|
+
audit_node_id=None,
|
|
2676
|
+
include_raw=True,
|
|
2677
|
+
)
|
|
2678
|
+
result = schema.get("result")
|
|
2679
|
+
return (result if isinstance(result, dict) else {}), label
|
|
2680
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2681
|
+
api_error = _coerce_api_error(error)
|
|
2682
|
+
last_error = error
|
|
2683
|
+
if api_error.http_status == 404:
|
|
2684
|
+
continue
|
|
2685
|
+
raise
|
|
2686
|
+
if last_error is not None:
|
|
2687
|
+
raise last_error
|
|
2688
|
+
return {}, "unknown"
|
|
2689
|
+
|
|
1476
2690
|
def _preview_target_app(
|
|
1477
2691
|
self,
|
|
1478
2692
|
*,
|
|
@@ -1516,33 +2730,72 @@ class AiBuilderFacade:
|
|
|
1516
2730
|
*,
|
|
1517
2731
|
profile: str,
|
|
1518
2732
|
app_key: str,
|
|
1519
|
-
|
|
2733
|
+
app_name: str,
|
|
1520
2734
|
package_tag_id: int | None,
|
|
1521
2735
|
create_if_missing: bool,
|
|
1522
2736
|
) -> JSONObject:
|
|
1523
2737
|
if app_key:
|
|
1524
2738
|
return self.app_resolve(profile=profile, app_key=app_key)
|
|
1525
|
-
if
|
|
1526
|
-
resolved = self.app_resolve(profile=profile, app_name=
|
|
2739
|
+
if app_name:
|
|
2740
|
+
resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
|
|
1527
2741
|
if resolved.get("status") == "success":
|
|
1528
2742
|
return resolved
|
|
1529
2743
|
if not create_if_missing:
|
|
1530
2744
|
return resolved
|
|
1531
2745
|
elif not create_if_missing:
|
|
1532
|
-
return _failed("APP_NAME_REQUIRED", "
|
|
2746
|
+
return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
|
|
1533
2747
|
if not create_if_missing:
|
|
1534
2748
|
return _failed("APP_NOT_FOUND", "target app was not found", suggested_next_call=None)
|
|
1535
2749
|
payload: JSONObject = {
|
|
1536
|
-
"appName":
|
|
2750
|
+
"appName": app_name or "未命名应用",
|
|
1537
2751
|
"auth": default_member_auth(),
|
|
1538
2752
|
"tagIds": [],
|
|
1539
2753
|
}
|
|
1540
|
-
|
|
2754
|
+
try:
|
|
2755
|
+
created = self.apps.app_create(profile=profile, payload=payload)
|
|
2756
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2757
|
+
api_error = _coerce_api_error(error)
|
|
2758
|
+
request_route = self._current_request_route(profile)
|
|
2759
|
+
return _failed_from_api_error(
|
|
2760
|
+
"CREATE_APP_ROUTE_NOT_FOUND" if api_error.http_status == 404 else "APP_CREATE_FAILED",
|
|
2761
|
+
api_error,
|
|
2762
|
+
details={
|
|
2763
|
+
"app_name": app_name,
|
|
2764
|
+
"package_tag_id": package_tag_id,
|
|
2765
|
+
"request_route": request_route,
|
|
2766
|
+
},
|
|
2767
|
+
suggested_next_call=(
|
|
2768
|
+
{"tool_name": "workspace_select", "arguments": {"profile": profile, "ws_id": request_route.get("ws_id")}}
|
|
2769
|
+
if request_route.get("ws_id")
|
|
2770
|
+
else None
|
|
2771
|
+
),
|
|
2772
|
+
)
|
|
1541
2773
|
result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
1542
2774
|
new_app_key = str(result.get("appKey") or (result.get("appKeys")[0] if isinstance(result.get("appKeys"), list) and result.get("appKeys") else ""))
|
|
1543
2775
|
if not new_app_key:
|
|
1544
2776
|
return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
|
|
1545
|
-
|
|
2777
|
+
try:
|
|
2778
|
+
base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
|
|
2779
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2780
|
+
api_error = _coerce_api_error(error)
|
|
2781
|
+
if api_error.http_status != 404:
|
|
2782
|
+
return _failed_from_api_error(
|
|
2783
|
+
"APP_CREATE_READBACK_FAILED",
|
|
2784
|
+
api_error,
|
|
2785
|
+
details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
|
|
2786
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": new_app_key}},
|
|
2787
|
+
)
|
|
2788
|
+
return {
|
|
2789
|
+
"status": "success",
|
|
2790
|
+
"error_code": None,
|
|
2791
|
+
"recoverable": False,
|
|
2792
|
+
"message": "created app; base readback pending",
|
|
2793
|
+
"suggested_next_call": None,
|
|
2794
|
+
"app_key": new_app_key,
|
|
2795
|
+
"app_name": app_name or "未命名应用",
|
|
2796
|
+
"tag_ids": [],
|
|
2797
|
+
"created": True,
|
|
2798
|
+
}
|
|
1546
2799
|
return {
|
|
1547
2800
|
"status": "success",
|
|
1548
2801
|
"error_code": None,
|
|
@@ -1550,11 +2803,56 @@ class AiBuilderFacade:
|
|
|
1550
2803
|
"message": "created app",
|
|
1551
2804
|
"suggested_next_call": None,
|
|
1552
2805
|
"app_key": new_app_key,
|
|
1553
|
-
"app_name": base.get("formTitle") or
|
|
2806
|
+
"app_name": base.get("formTitle") or app_name or "未命名应用",
|
|
1554
2807
|
"tag_ids": _coerce_int_list(base.get("tagIds")),
|
|
1555
2808
|
"created": True,
|
|
1556
2809
|
}
|
|
1557
2810
|
|
|
2811
|
+
def _current_request_route(self, profile: str) -> JSONObject:
|
|
2812
|
+
session_profile = self.apps.sessions.get_profile(profile)
|
|
2813
|
+
backend_session = self.apps.sessions.get_backend_session(profile)
|
|
2814
|
+
if session_profile is None or backend_session is None:
|
|
2815
|
+
return {}
|
|
2816
|
+
context = BackendRequestContext(
|
|
2817
|
+
base_url=backend_session.base_url,
|
|
2818
|
+
token=backend_session.token,
|
|
2819
|
+
ws_id=session_profile.selected_ws_id,
|
|
2820
|
+
qf_version=backend_session.qf_version,
|
|
2821
|
+
qf_version_source=backend_session.qf_version_source,
|
|
2822
|
+
)
|
|
2823
|
+
describe_route = getattr(self.apps.backend, "describe_route", None)
|
|
2824
|
+
route = describe_route(context) if callable(describe_route) else None
|
|
2825
|
+
payload = dict(route) if isinstance(route, dict) else {}
|
|
2826
|
+
payload.setdefault("ws_id", session_profile.selected_ws_id)
|
|
2827
|
+
return payload
|
|
2828
|
+
|
|
2829
|
+
def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
|
|
2830
|
+
session_profile = self.apps.sessions.get_profile(profile)
|
|
2831
|
+
backend_session = self.apps.sessions.get_backend_session(profile)
|
|
2832
|
+
current_email = str((session_profile.email if session_profile else None) or "").strip()
|
|
2833
|
+
current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
|
|
2834
|
+
if current_email or current_name or session_profile is None or backend_session is None:
|
|
2835
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
2836
|
+
try:
|
|
2837
|
+
user_info = self.apps.backend.request(
|
|
2838
|
+
"GET",
|
|
2839
|
+
BackendRequestContext(
|
|
2840
|
+
base_url=backend_session.base_url,
|
|
2841
|
+
token=backend_session.token,
|
|
2842
|
+
ws_id=session_profile.selected_ws_id,
|
|
2843
|
+
qf_version=backend_session.qf_version,
|
|
2844
|
+
qf_version_source=backend_session.qf_version_source,
|
|
2845
|
+
),
|
|
2846
|
+
"/user",
|
|
2847
|
+
)
|
|
2848
|
+
except (QingflowApiError, RuntimeError):
|
|
2849
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
2850
|
+
if not isinstance(user_info, dict):
|
|
2851
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
2852
|
+
resolved_email = str(user_info.get("email") or "").strip() or None
|
|
2853
|
+
resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
|
|
2854
|
+
return {"email": resolved_email, "nick_name": resolved_name}
|
|
2855
|
+
|
|
1558
2856
|
def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
|
|
1559
2857
|
detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
|
|
1560
2858
|
result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
|
|
@@ -1619,18 +2917,49 @@ def _failed_from_api_error(
|
|
|
1619
2917
|
suggested_next_call: JSONObject | None = None,
|
|
1620
2918
|
recoverable: bool = True,
|
|
1621
2919
|
) -> JSONObject:
|
|
2920
|
+
effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
|
|
2921
|
+
public_message = _public_error_message(effective_error_code, error)
|
|
2922
|
+
public_http_status = None if error.http_status == 404 else error.http_status
|
|
2923
|
+
merged_details = dict(details or {})
|
|
2924
|
+
if error.backend_code == 40074:
|
|
2925
|
+
owner = _extract_edit_lock_owner(error.message)
|
|
2926
|
+
merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
|
|
2927
|
+
merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
|
|
2928
|
+
app_key = None
|
|
2929
|
+
if isinstance(normalized_args, dict):
|
|
2930
|
+
app_key = normalized_args.get("app_key")
|
|
2931
|
+
if not app_key and isinstance(details, dict):
|
|
2932
|
+
app_key = details.get("app_key")
|
|
2933
|
+
if isinstance(app_key, str) and app_key.strip():
|
|
2934
|
+
suggested_next_call = {
|
|
2935
|
+
"tool_name": "app_release_edit_lock_if_mine",
|
|
2936
|
+
"arguments": {
|
|
2937
|
+
"app_key": app_key,
|
|
2938
|
+
"lock_owner_name": owner.get("lock_owner_name") or "",
|
|
2939
|
+
"lock_owner_email": owner.get("lock_owner_email") or "",
|
|
2940
|
+
},
|
|
2941
|
+
}
|
|
2942
|
+
if error.http_status is not None or error.backend_code is not None:
|
|
2943
|
+
merged_details.setdefault(
|
|
2944
|
+
"transport_error",
|
|
2945
|
+
{
|
|
2946
|
+
"http_status": error.http_status,
|
|
2947
|
+
"backend_code": error.backend_code,
|
|
2948
|
+
"category": error.category,
|
|
2949
|
+
},
|
|
2950
|
+
)
|
|
1622
2951
|
return _failed(
|
|
1623
|
-
|
|
1624
|
-
|
|
2952
|
+
effective_error_code,
|
|
2953
|
+
public_message,
|
|
1625
2954
|
recoverable=recoverable,
|
|
1626
2955
|
normalized_args=normalized_args,
|
|
1627
2956
|
missing_fields=missing_fields,
|
|
1628
2957
|
allowed_values=allowed_values,
|
|
1629
|
-
details=
|
|
2958
|
+
details=merged_details,
|
|
1630
2959
|
suggested_next_call=suggested_next_call,
|
|
1631
2960
|
request_id=error.request_id,
|
|
1632
2961
|
backend_code=error.backend_code,
|
|
1633
|
-
http_status=
|
|
2962
|
+
http_status=public_http_status,
|
|
1634
2963
|
)
|
|
1635
2964
|
|
|
1636
2965
|
|
|
@@ -1669,6 +2998,52 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
|
|
|
1669
2998
|
return QingflowApiError(category="runtime", message=str(error))
|
|
1670
2999
|
|
|
1671
3000
|
|
|
3001
|
+
def _public_error_message(error_code: str, error: QingflowApiError) -> str:
|
|
3002
|
+
if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
|
|
3003
|
+
owner = _extract_edit_lock_owner(error.message)
|
|
3004
|
+
owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
|
|
3005
|
+
if owner_label:
|
|
3006
|
+
return f"app is currently locked by active editor {owner_label}"
|
|
3007
|
+
return "app is currently locked by another active editor session"
|
|
3008
|
+
if error.http_status != 404:
|
|
3009
|
+
return error.message
|
|
3010
|
+
mapping = {
|
|
3011
|
+
"APP_READ_FAILED": "app base or schema is unavailable in the current route",
|
|
3012
|
+
"FIELDS_READ_FAILED": "app fields are unavailable in the current route",
|
|
3013
|
+
"LAYOUT_READ_FAILED": "layout resource is unavailable for this app in the current route",
|
|
3014
|
+
"VIEWS_READ_FAILED": "views resource is unavailable for this app in the current route",
|
|
3015
|
+
"FLOW_READ_FAILED": "workflow resource is unavailable for this app in the current route",
|
|
3016
|
+
"SCHEMA_READBACK_FAILED": "schema was written but schema readback is unavailable in the current route",
|
|
3017
|
+
"CREATE_APP_ROUTE_NOT_FOUND": "create app route is unavailable in the current workspace route",
|
|
3018
|
+
"APP_CREATE_READBACK_FAILED": "app was created but base readback is unavailable in the current route",
|
|
3019
|
+
"PACKAGE_ATTACH_FAILED": "package attachment could not be verified in the current route",
|
|
3020
|
+
"PUBLISH_FAILED": "publish route is unavailable in the current route",
|
|
3021
|
+
"VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
|
|
3022
|
+
"LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
|
|
3023
|
+
"SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
|
|
3024
|
+
"EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
|
|
3025
|
+
}
|
|
3026
|
+
return mapping.get(error_code, "requested builder resource is unavailable in the current route")
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
def _extract_edit_lock_owner(message: str) -> JSONObject:
|
|
3030
|
+
text = str(message or "").strip()
|
|
3031
|
+
if not text:
|
|
3032
|
+
return {"lock_owner_name": None, "lock_owner_email": None}
|
|
3033
|
+
patterns = [
|
|
3034
|
+
r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
|
|
3035
|
+
r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
|
|
3036
|
+
]
|
|
3037
|
+
for pattern in patterns:
|
|
3038
|
+
match = re.search(pattern, text)
|
|
3039
|
+
if match:
|
|
3040
|
+
return {
|
|
3041
|
+
"lock_owner_name": match.groupdict().get("name", "").strip() or None,
|
|
3042
|
+
"lock_owner_email": match.groupdict().get("email", "").strip() or None,
|
|
3043
|
+
}
|
|
3044
|
+
return {"lock_owner_name": None, "lock_owner_email": None}
|
|
3045
|
+
|
|
3046
|
+
|
|
1672
3047
|
def _coerce_positive_int(value: Any) -> int | None:
|
|
1673
3048
|
if isinstance(value, bool) or value is None:
|
|
1674
3049
|
return None
|
|
@@ -1696,6 +3071,34 @@ def _coerce_int_list(values: Any) -> list[int]:
|
|
|
1696
3071
|
return result
|
|
1697
3072
|
|
|
1698
3073
|
|
|
3074
|
+
def _normalize_view_collection(values: Any) -> list[dict[str, Any]]:
|
|
3075
|
+
if isinstance(values, list):
|
|
3076
|
+
return [item for item in values if isinstance(item, dict)]
|
|
3077
|
+
if isinstance(values, dict):
|
|
3078
|
+
for key in ("list", "viewList", "views", "result"):
|
|
3079
|
+
candidate = values.get(key)
|
|
3080
|
+
if isinstance(candidate, list):
|
|
3081
|
+
return [item for item in candidate if isinstance(item, dict)]
|
|
3082
|
+
return []
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
def _is_view_collection_shape(values: Any) -> bool:
|
|
3086
|
+
if isinstance(values, list):
|
|
3087
|
+
return True
|
|
3088
|
+
if isinstance(values, dict):
|
|
3089
|
+
return any(isinstance(values.get(key), list) for key in ("list", "viewList", "views", "result"))
|
|
3090
|
+
return False
|
|
3091
|
+
|
|
3092
|
+
|
|
3093
|
+
def _empty_schema_result(title: str) -> dict[str, Any]:
|
|
3094
|
+
return {
|
|
3095
|
+
"formTitle": title,
|
|
3096
|
+
"editVersionNo": 1,
|
|
3097
|
+
"formQues": [],
|
|
3098
|
+
"questionRelations": [],
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
|
|
1699
3102
|
def _slugify(text: str, *, default: str) -> str:
|
|
1700
3103
|
normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(text or ""))
|
|
1701
3104
|
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
@@ -1714,6 +3117,7 @@ def _infer_field_type(question: dict[str, Any]) -> str:
|
|
|
1714
3117
|
def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None) -> dict[str, Any]:
|
|
1715
3118
|
name = str(question.get("queTitle") or "").strip()
|
|
1716
3119
|
que_id = _coerce_positive_int(question.get("queId"))
|
|
3120
|
+
que_type = _coerce_positive_int(question.get("queType"))
|
|
1717
3121
|
field_type = _infer_field_type(question)
|
|
1718
3122
|
field_id = field_id_hint or f"field_{que_id or _slugify(name, default='x')}"
|
|
1719
3123
|
field: dict[str, Any] = {
|
|
@@ -1726,6 +3130,7 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
|
|
|
1726
3130
|
"target_app_key": None,
|
|
1727
3131
|
"subfields": [],
|
|
1728
3132
|
"que_id": que_id,
|
|
3133
|
+
"que_type": que_type,
|
|
1729
3134
|
}
|
|
1730
3135
|
if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
|
|
1731
3136
|
options = question.get("options")
|
|
@@ -2073,11 +3478,76 @@ def _build_flow_preset(preset: FlowPreset) -> tuple[list[dict[str, Any]], list[d
|
|
|
2073
3478
|
return nodes, transitions
|
|
2074
3479
|
|
|
2075
3480
|
|
|
3481
|
+
def _merge_flow_graph(
|
|
3482
|
+
*,
|
|
3483
|
+
base_nodes: list[dict[str, Any]],
|
|
3484
|
+
base_transitions: list[dict[str, Any]],
|
|
3485
|
+
override_nodes: list[dict[str, Any]],
|
|
3486
|
+
override_transitions: list[dict[str, Any]],
|
|
3487
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
3488
|
+
if not override_nodes and not override_transitions:
|
|
3489
|
+
return deepcopy(base_nodes), deepcopy(base_transitions)
|
|
3490
|
+
merged_nodes: list[dict[str, Any]] = []
|
|
3491
|
+
override_map = {
|
|
3492
|
+
str(node.get("id") or ""): deepcopy(node)
|
|
3493
|
+
for node in override_nodes
|
|
3494
|
+
if isinstance(node, dict) and str(node.get("id") or "")
|
|
3495
|
+
}
|
|
3496
|
+
consumed: set[str] = set()
|
|
3497
|
+
for node in base_nodes:
|
|
3498
|
+
node_id = str(node.get("id") or "")
|
|
3499
|
+
if node_id and node_id in override_map:
|
|
3500
|
+
merged = deepcopy(node)
|
|
3501
|
+
merged.update(override_map[node_id])
|
|
3502
|
+
if isinstance(node.get("config"), dict) and isinstance(override_map[node_id].get("config"), dict):
|
|
3503
|
+
merged["config"] = {**deepcopy(node["config"]), **deepcopy(override_map[node_id]["config"])}
|
|
3504
|
+
if isinstance(node.get("assignees"), dict) and isinstance(override_map[node_id].get("assignees"), dict):
|
|
3505
|
+
merged["assignees"] = {**deepcopy(node["assignees"]), **deepcopy(override_map[node_id]["assignees"])}
|
|
3506
|
+
if isinstance(node.get("permissions"), dict) and isinstance(override_map[node_id].get("permissions"), dict):
|
|
3507
|
+
merged["permissions"] = {**deepcopy(node["permissions"]), **deepcopy(override_map[node_id]["permissions"])}
|
|
3508
|
+
merged_nodes.append(merged)
|
|
3509
|
+
consumed.add(node_id)
|
|
3510
|
+
else:
|
|
3511
|
+
merged_nodes.append(deepcopy(node))
|
|
3512
|
+
for node in override_nodes:
|
|
3513
|
+
node_id = str(node.get("id") or "")
|
|
3514
|
+
if node_id and node_id not in consumed:
|
|
3515
|
+
merged_nodes.append(deepcopy(node))
|
|
3516
|
+
merged_transitions = deepcopy(override_transitions) if override_transitions else deepcopy(base_transitions)
|
|
3517
|
+
return merged_nodes, merged_transitions
|
|
3518
|
+
|
|
3519
|
+
|
|
3520
|
+
def _extract_directory_items(listed: JSONObject) -> list[dict[str, Any]]:
|
|
3521
|
+
if isinstance(listed.get("items"), list):
|
|
3522
|
+
return [item for item in listed["items"] if isinstance(item, dict)]
|
|
3523
|
+
result = listed.get("result")
|
|
3524
|
+
if isinstance(result, dict) and isinstance(result.get("result"), list):
|
|
3525
|
+
return [item for item in result["result"] if isinstance(item, dict)]
|
|
3526
|
+
if isinstance(result, list):
|
|
3527
|
+
return [item for item in result if isinstance(item, dict)]
|
|
3528
|
+
return []
|
|
3529
|
+
|
|
3530
|
+
|
|
2076
3531
|
def _build_views_preset(preset: ViewsPreset, field_names: list[str]) -> list[dict[str, Any]]:
|
|
2077
3532
|
ordered = [name for name in field_names if name]
|
|
2078
3533
|
if preset == ViewsPreset.status_board:
|
|
2079
3534
|
group_by = next((name for name in ordered if "状态" in name), ordered[0] if ordered else "")
|
|
2080
3535
|
return [{"name": "按状态看板", "type": "board", "group_by": group_by, "columns": ordered[:3] or ([group_by] if group_by else [])}]
|
|
3536
|
+
if preset == ViewsPreset.default_gantt:
|
|
3537
|
+
title_like = next((name for name in ordered if "名称" in name or "标题" in name), ordered[0] if ordered else "")
|
|
3538
|
+
start_like = next((name for name in ordered if "开始" in name or "起始" in name), "")
|
|
3539
|
+
end_like = next((name for name in ordered if "结束" in name or "截止" in name or "完成" in name), "")
|
|
3540
|
+
columns = [name for name in (title_like, start_like, end_like) if name]
|
|
3541
|
+
return [
|
|
3542
|
+
{
|
|
3543
|
+
"name": "项目甘特图",
|
|
3544
|
+
"type": "gantt",
|
|
3545
|
+
"columns": columns,
|
|
3546
|
+
"start_field": start_like or None,
|
|
3547
|
+
"end_field": end_like or None,
|
|
3548
|
+
"title_field": title_like or None,
|
|
3549
|
+
}
|
|
3550
|
+
]
|
|
2081
3551
|
return [{"name": "全部数据", "type": "table", "columns": ordered[: min(5, len(ordered))]}]
|
|
2082
3552
|
|
|
2083
3553
|
|
|
@@ -2337,8 +3807,16 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
|
|
|
2337
3807
|
if node_map[parent_id].get("type") == "branch":
|
|
2338
3808
|
payload["branch_parent_id"] = parent_id
|
|
2339
3809
|
payload["branch_index"] = outbound[parent_id].index(node_id) + 1
|
|
2340
|
-
if isinstance(node.get("config"), dict)
|
|
2341
|
-
|
|
3810
|
+
config_payload = deepcopy(node.get("config") or {}) if isinstance(node.get("config"), dict) else {}
|
|
3811
|
+
permissions = node.get("permissions") or {}
|
|
3812
|
+
editable_que_ids = permissions.get("editable_que_ids") or []
|
|
3813
|
+
if editable_que_ids:
|
|
3814
|
+
config_payload["editableQueIds"] = editable_que_ids
|
|
3815
|
+
if config_payload:
|
|
3816
|
+
payload["config"] = config_payload
|
|
3817
|
+
assignees = node.get("assignees") or {}
|
|
3818
|
+
if assignees:
|
|
3819
|
+
payload["assignees"] = deepcopy(assignees)
|
|
2342
3820
|
internal_nodes.append(payload)
|
|
2343
3821
|
return {
|
|
2344
3822
|
"status": "success",
|
|
@@ -2346,6 +3824,91 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
|
|
|
2346
3824
|
}
|
|
2347
3825
|
|
|
2348
3826
|
|
|
3827
|
+
def _build_flow_condition_matrix(
|
|
3828
|
+
*,
|
|
3829
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
3830
|
+
node: dict[str, Any],
|
|
3831
|
+
) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
|
|
3832
|
+
if str(node.get("type") or "") != "condition":
|
|
3833
|
+
return [], []
|
|
3834
|
+
raw_groups = node.get("condition_groups") or []
|
|
3835
|
+
if not isinstance(raw_groups, list):
|
|
3836
|
+
return [], []
|
|
3837
|
+
groups: list[list[dict[str, Any]]] = []
|
|
3838
|
+
issues: list[dict[str, Any]] = []
|
|
3839
|
+
for raw_group in raw_groups:
|
|
3840
|
+
if not isinstance(raw_group, list):
|
|
3841
|
+
continue
|
|
3842
|
+
translated_group: list[dict[str, Any]] = []
|
|
3843
|
+
for raw_rule in raw_group:
|
|
3844
|
+
if not isinstance(raw_rule, dict):
|
|
3845
|
+
continue
|
|
3846
|
+
field_name = str(raw_rule.get("field_name") or "").strip()
|
|
3847
|
+
field = current_fields_by_name.get(field_name)
|
|
3848
|
+
if field is None:
|
|
3849
|
+
issues.append(
|
|
3850
|
+
{
|
|
3851
|
+
"kind": "condition_fields",
|
|
3852
|
+
"error_code": "UNKNOWN_FLOW_FIELD",
|
|
3853
|
+
"missing_fields": [field_name] if field_name else [],
|
|
3854
|
+
}
|
|
3855
|
+
)
|
|
3856
|
+
continue
|
|
3857
|
+
translated_group.append(_translate_flow_condition_rule(field=field, rule=raw_rule))
|
|
3858
|
+
if translated_group:
|
|
3859
|
+
groups.append(translated_group)
|
|
3860
|
+
return groups, issues
|
|
3861
|
+
|
|
3862
|
+
|
|
3863
|
+
def _translate_flow_condition_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]:
|
|
3864
|
+
operator = str(rule.get("operator") or "").strip().lower()
|
|
3865
|
+
values = list(rule.get("values") or [])
|
|
3866
|
+
field_type = str(field.get("type") or FieldType.text.value)
|
|
3867
|
+
que_id = _coerce_positive_int(field.get("que_id")) or 0
|
|
3868
|
+
que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
|
|
3869
|
+
base: dict[str, Any] = {
|
|
3870
|
+
"queId": que_id,
|
|
3871
|
+
"queTitle": str(field.get("name") or ""),
|
|
3872
|
+
"queType": que_type,
|
|
3873
|
+
"matchType": MATCH_TYPE_ACCURACY,
|
|
3874
|
+
}
|
|
3875
|
+
if operator == "eq":
|
|
3876
|
+
base["judgeType"] = JUDGE_EQUAL
|
|
3877
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
3878
|
+
elif operator == "neq":
|
|
3879
|
+
base["judgeType"] = JUDGE_UNEQUAL
|
|
3880
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
3881
|
+
elif operator == "in":
|
|
3882
|
+
base["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
|
|
3883
|
+
base["judgeValues"] = [_stringify_condition_value(value) for value in values]
|
|
3884
|
+
elif operator == "contains":
|
|
3885
|
+
base["judgeType"] = JUDGE_FUZZY_MATCH
|
|
3886
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
3887
|
+
elif operator == "gte":
|
|
3888
|
+
base["judgeType"] = JUDGE_GREATER_OR_EQUAL
|
|
3889
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
3890
|
+
elif operator == "lte":
|
|
3891
|
+
base["judgeType"] = JUDGE_LESS_OR_EQUAL
|
|
3892
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
3893
|
+
elif operator == "is_empty":
|
|
3894
|
+
base["judgeType"] = JUDGE_EQUAL
|
|
3895
|
+
base["judgeValues"] = []
|
|
3896
|
+
elif operator == "not_empty":
|
|
3897
|
+
base["judgeType"] = JUDGE_UNEQUAL
|
|
3898
|
+
base["judgeValues"] = []
|
|
3899
|
+
return base
|
|
3900
|
+
|
|
3901
|
+
|
|
3902
|
+
def _stringify_condition_value(value: Any) -> str:
|
|
3903
|
+
if isinstance(value, bool):
|
|
3904
|
+
return "true" if value else "false"
|
|
3905
|
+
if value is None:
|
|
3906
|
+
return ""
|
|
3907
|
+
if isinstance(value, (dict, list)):
|
|
3908
|
+
return json.dumps(value, ensure_ascii=False)
|
|
3909
|
+
return str(value)
|
|
3910
|
+
|
|
3911
|
+
|
|
2349
3912
|
def _build_view_create_payload(
|
|
2350
3913
|
*,
|
|
2351
3914
|
app_key: str,
|
|
@@ -2353,15 +3916,20 @@ def _build_view_create_payload(
|
|
|
2353
3916
|
schema: dict[str, Any],
|
|
2354
3917
|
patch: ViewUpsertPatch,
|
|
2355
3918
|
ordinal: int,
|
|
3919
|
+
view_filters: list[list[dict[str, Any]]],
|
|
2356
3920
|
) -> JSONObject:
|
|
2357
3921
|
entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
|
|
2358
|
-
|
|
3922
|
+
parsed_schema = _parse_schema(schema)
|
|
3923
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
3924
|
+
field_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
|
|
3925
|
+
gantt_config = _build_public_gantt_payload(parsed_schema["fields"], extract_field_map(schema), patch)
|
|
2359
3926
|
view_spec = ViewSpec(
|
|
2360
3927
|
view_id=_slugify(patch.name, default=f"view_{uuid4().hex[:6]}"),
|
|
2361
3928
|
name=patch.name,
|
|
2362
3929
|
type=patch.type.value,
|
|
2363
3930
|
field_ids=field_ids,
|
|
2364
3931
|
group_by_field_id=_field_id_for_name(_parse_schema(schema)["fields"], patch.group_by) if patch.group_by else None,
|
|
3932
|
+
config=gantt_config,
|
|
2365
3933
|
)
|
|
2366
3934
|
from ..solution.spec_models import EntitySpec
|
|
2367
3935
|
from ..solution.compiler.view_compiler import compile_views
|
|
@@ -2369,7 +3937,7 @@ def _build_view_create_payload(
|
|
|
2369
3937
|
compiled = compile_views(EntitySpec.model_validate({**entity, "views": [view_spec.model_dump(mode="json")]}))[0]
|
|
2370
3938
|
field_map = extract_field_map(schema)
|
|
2371
3939
|
payload = deepcopy(compiled["create_payload"])
|
|
2372
|
-
visible_que_ids = [field_map[field_name] for field_name in
|
|
3940
|
+
visible_que_ids = [field_map[field_name] for field_name in visible_field_names if field_name in field_map]
|
|
2373
3941
|
payload["appKey"] = app_key
|
|
2374
3942
|
payload["ordinal"] = ordinal
|
|
2375
3943
|
payload["viewgraphQueIds"] = visible_que_ids
|
|
@@ -2377,19 +3945,19 @@ def _build_view_create_payload(
|
|
|
2377
3945
|
payload["auth"] = default_member_auth()
|
|
2378
3946
|
payload.setdefault("sortType", "defaultSort")
|
|
2379
3947
|
payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
|
|
2380
|
-
|
|
2381
|
-
payload.setdefault("beingShowCover", False)
|
|
2382
|
-
payload.setdefault("defaultRowHigh", "compact")
|
|
2383
|
-
payload.setdefault("asosChartVisible", False)
|
|
2384
|
-
payload.setdefault("viewgraphLimitType", 1)
|
|
2385
|
-
payload.setdefault("viewgraphLimit", [])
|
|
2386
|
-
payload.setdefault("buttonConfigDTOList", [])
|
|
2387
|
-
if patch.type.value in {"card", "board"}:
|
|
3948
|
+
if patch.type.value in {"card", "board", "gantt"}:
|
|
2388
3949
|
payload["beingShowTitleQue"] = True
|
|
2389
|
-
payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
|
|
3950
|
+
payload["titleQue"] = gantt_config.get("titleQueId") or (visible_que_ids[0] if visible_que_ids else None)
|
|
2390
3951
|
if patch.type.value == "board":
|
|
2391
3952
|
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
2392
|
-
return
|
|
3953
|
+
return _hydrate_view_backend_payload(
|
|
3954
|
+
payload=payload,
|
|
3955
|
+
view_type=patch.type.value,
|
|
3956
|
+
visible_que_id_values=visible_que_ids,
|
|
3957
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
3958
|
+
view_filters=view_filters,
|
|
3959
|
+
gantt_payload=gantt_config,
|
|
3960
|
+
)
|
|
2393
3961
|
|
|
2394
3962
|
|
|
2395
3963
|
def _build_form_payload_from_existing_schema(
|
|
@@ -2512,6 +4080,8 @@ def _pick_view_template_key(existing_views: list[dict[str, Any]], *, desired_typ
|
|
|
2512
4080
|
|
|
2513
4081
|
def _normalize_view_type_name(value: Any) -> str:
|
|
2514
4082
|
normalized = str(value or "").strip().lower()
|
|
4083
|
+
if "gantt" in normalized:
|
|
4084
|
+
return "gantt"
|
|
2515
4085
|
if "board" in normalized:
|
|
2516
4086
|
return "board"
|
|
2517
4087
|
if "card" in normalized:
|
|
@@ -2526,14 +4096,17 @@ def _build_view_update_payload(
|
|
|
2526
4096
|
source_viewgraph_key: str,
|
|
2527
4097
|
schema: dict[str, Any],
|
|
2528
4098
|
patch: ViewUpsertPatch,
|
|
4099
|
+
view_filters: list[list[dict[str, Any]]],
|
|
2529
4100
|
) -> JSONObject:
|
|
2530
4101
|
config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
|
|
2531
4102
|
config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
|
|
2532
4103
|
payload = deepcopy(config)
|
|
2533
4104
|
parsed_schema = _parse_schema(schema)
|
|
2534
4105
|
field_map = extract_field_map(schema)
|
|
2535
|
-
|
|
2536
|
-
|
|
4106
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
4107
|
+
visible_que_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
|
|
4108
|
+
visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
|
|
4109
|
+
gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
|
|
2537
4110
|
|
|
2538
4111
|
for key in (
|
|
2539
4112
|
"appKey",
|
|
@@ -2561,7 +4134,7 @@ def _build_view_update_payload(
|
|
|
2561
4134
|
payload.setdefault("beingShowCover", False)
|
|
2562
4135
|
payload.setdefault("defaultRowHigh", "compact")
|
|
2563
4136
|
payload.setdefault("viewgraphLimitType", 1)
|
|
2564
|
-
payload.setdefault("viewgraphLimit", [])
|
|
4137
|
+
payload.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
|
|
2565
4138
|
payload.setdefault("buttonConfigDTOList", [])
|
|
2566
4139
|
|
|
2567
4140
|
normalized_type = patch.type.value
|
|
@@ -2581,9 +4154,264 @@ def _build_view_update_payload(
|
|
|
2581
4154
|
payload["beingShowTitleQue"] = True
|
|
2582
4155
|
payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
2583
4156
|
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
4157
|
+
elif normalized_type == "gantt":
|
|
4158
|
+
payload["viewgraphType"] = "ganttView"
|
|
4159
|
+
payload["beingShowTitleQue"] = True
|
|
4160
|
+
payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4161
|
+
payload.pop("groupQueId", None)
|
|
4162
|
+
return _hydrate_view_backend_payload(
|
|
4163
|
+
payload=payload,
|
|
4164
|
+
view_type=normalized_type,
|
|
4165
|
+
visible_que_id_values=visible_que_id_values,
|
|
4166
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
4167
|
+
view_filters=view_filters,
|
|
4168
|
+
gantt_payload=gantt_payload,
|
|
4169
|
+
)
|
|
4170
|
+
|
|
4171
|
+
|
|
4172
|
+
def _build_minimal_view_payload(
|
|
4173
|
+
*,
|
|
4174
|
+
app_key: str,
|
|
4175
|
+
schema: dict[str, Any],
|
|
4176
|
+
patch: ViewUpsertPatch,
|
|
4177
|
+
ordinal: int,
|
|
4178
|
+
view_filters: list[list[dict[str, Any]]],
|
|
4179
|
+
) -> JSONObject:
|
|
4180
|
+
field_map = extract_field_map(schema)
|
|
4181
|
+
parsed_schema = _parse_schema(schema)
|
|
4182
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
4183
|
+
visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
|
|
4184
|
+
gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
|
|
4185
|
+
payload: JSONObject = {
|
|
4186
|
+
"appKey": app_key,
|
|
4187
|
+
"viewgraphName": patch.name,
|
|
4188
|
+
"viewgraphType": {
|
|
4189
|
+
"table": "tableView",
|
|
4190
|
+
"card": "cardView",
|
|
4191
|
+
"board": "boardView",
|
|
4192
|
+
"gantt": "ganttView",
|
|
4193
|
+
}[patch.type.value],
|
|
4194
|
+
"ordinal": ordinal,
|
|
4195
|
+
"viewgraphQueIds": visible_que_id_values,
|
|
4196
|
+
"viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
|
|
4197
|
+
"auth": default_member_auth(),
|
|
4198
|
+
}
|
|
4199
|
+
if patch.type.value in {"card", "board", "gantt"}:
|
|
4200
|
+
payload["beingShowTitleQue"] = True
|
|
4201
|
+
payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4202
|
+
if patch.type.value == "board":
|
|
4203
|
+
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
4204
|
+
return _hydrate_view_backend_payload(
|
|
4205
|
+
payload=payload,
|
|
4206
|
+
view_type=patch.type.value,
|
|
4207
|
+
visible_que_id_values=visible_que_id_values,
|
|
4208
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
4209
|
+
view_filters=view_filters,
|
|
4210
|
+
gantt_payload=gantt_payload,
|
|
4211
|
+
)
|
|
4212
|
+
|
|
4213
|
+
|
|
4214
|
+
def _hydrate_view_backend_payload(
|
|
4215
|
+
*,
|
|
4216
|
+
payload: JSONObject,
|
|
4217
|
+
view_type: str,
|
|
4218
|
+
visible_que_id_values: list[int],
|
|
4219
|
+
group_que_id: int | None,
|
|
4220
|
+
view_filters: list[list[dict[str, Any]]] | None = None,
|
|
4221
|
+
gantt_payload: dict[str, Any] | None = None,
|
|
4222
|
+
) -> JSONObject:
|
|
4223
|
+
data = deepcopy(payload)
|
|
4224
|
+
data.setdefault("beingPinNavigate", True)
|
|
4225
|
+
data.setdefault("beingNeedPass", False)
|
|
4226
|
+
data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
|
|
4227
|
+
data.setdefault("beingShowCover", False)
|
|
4228
|
+
data.setdefault("defaultRowHigh", "compact")
|
|
4229
|
+
data.setdefault("asosChartVisible", False)
|
|
4230
|
+
data.setdefault("viewgraphPass", "")
|
|
4231
|
+
data.setdefault("beingGroupColor", False)
|
|
4232
|
+
data.setdefault("beingShowQueTitle", True)
|
|
4233
|
+
data.setdefault("beingImageAdaption", False)
|
|
4234
|
+
data.setdefault("clippingMode", "default")
|
|
4235
|
+
data.setdefault("frontCoverQueId", None)
|
|
4236
|
+
data.setdefault("viewgraphLimitType", 1)
|
|
4237
|
+
data.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
|
|
4238
|
+
data.setdefault("viewgraphLimitFormula", "")
|
|
4239
|
+
data.setdefault("sortType", "defaultSort")
|
|
4240
|
+
if not data.get("viewgraphSorts"):
|
|
4241
|
+
sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
|
|
4242
|
+
data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
|
|
4243
|
+
data.setdefault("beingAuditRecordVisible", True)
|
|
4244
|
+
data.setdefault("beingQrobotRecordVisible", False)
|
|
4245
|
+
data.setdefault("beingPrintStatus", False)
|
|
4246
|
+
data.setdefault("beingDefaultPrintTplStatus", False)
|
|
4247
|
+
data.setdefault("printTpls", [])
|
|
4248
|
+
data.setdefault("beingCommentStatus", False)
|
|
4249
|
+
data.setdefault("usages", [])
|
|
4250
|
+
data.setdefault("dataPermissionType", "CUSTOM")
|
|
4251
|
+
data.setdefault("dataScope", "ALL")
|
|
4252
|
+
data.setdefault("needPass", False)
|
|
4253
|
+
data.setdefault("beingWorkflowNodeFutureListVisible", True)
|
|
4254
|
+
data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
|
|
4255
|
+
data.setdefault("viewgraphGanttConfigVO", None)
|
|
4256
|
+
data.setdefault("viewgraphHierarchyConfigVO", None)
|
|
4257
|
+
data.setdefault("buttonConfigDTOList", [])
|
|
4258
|
+
if "buttonConfig" not in data:
|
|
4259
|
+
data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
|
|
4260
|
+
if view_type == "table":
|
|
4261
|
+
data["viewgraphType"] = "tableView"
|
|
4262
|
+
data["beingShowTitleQue"] = False
|
|
4263
|
+
data["titleQue"] = None
|
|
4264
|
+
data["groupQueId"] = None
|
|
4265
|
+
elif view_type == "card":
|
|
4266
|
+
data["viewgraphType"] = "cardView"
|
|
4267
|
+
data["beingShowTitleQue"] = True
|
|
4268
|
+
data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
4269
|
+
data["groupQueId"] = None
|
|
4270
|
+
elif view_type == "board":
|
|
4271
|
+
data["viewgraphType"] = "boardView"
|
|
4272
|
+
data["beingShowTitleQue"] = True
|
|
4273
|
+
data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
4274
|
+
data["groupQueId"] = group_que_id
|
|
4275
|
+
elif view_type == "gantt":
|
|
4276
|
+
data["viewgraphType"] = "ganttView"
|
|
4277
|
+
data["beingShowTitleQue"] = True
|
|
4278
|
+
data["titleQue"] = (gantt_payload or {}).get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4279
|
+
data["groupQueId"] = None
|
|
4280
|
+
data["viewgraphGanttConfigVO"] = deepcopy(gantt_payload) if gantt_payload else None
|
|
4281
|
+
if view_filters is not None:
|
|
4282
|
+
data["viewgraphLimit"] = deepcopy(view_filters)
|
|
4283
|
+
return data
|
|
4284
|
+
|
|
4285
|
+
|
|
4286
|
+
def _resolve_view_visible_field_names(patch: ViewUpsertPatch) -> list[str]:
|
|
4287
|
+
ordered: list[str] = []
|
|
4288
|
+
for value in [*patch.columns, patch.title_field, patch.start_field, patch.end_field, patch.group_by]:
|
|
4289
|
+
name = str(value or "").strip()
|
|
4290
|
+
if name and name not in ordered:
|
|
4291
|
+
ordered.append(name)
|
|
4292
|
+
return ordered
|
|
4293
|
+
|
|
4294
|
+
|
|
4295
|
+
def _build_public_gantt_payload(
|
|
4296
|
+
fields: list[dict[str, Any]],
|
|
4297
|
+
field_map: dict[str, int],
|
|
4298
|
+
patch: ViewUpsertPatch,
|
|
4299
|
+
) -> dict[str, Any]:
|
|
4300
|
+
if patch.type.value != "gantt":
|
|
4301
|
+
return {}
|
|
4302
|
+
title_field_name = str((patch.title_field or (patch.columns[0] if patch.columns else "")) or "").strip()
|
|
4303
|
+
start_field_name = str(patch.start_field or "").strip()
|
|
4304
|
+
end_field_name = str(patch.end_field or "").strip()
|
|
4305
|
+
return {
|
|
4306
|
+
"titleQueId": field_map.get(title_field_name),
|
|
4307
|
+
"startTimeQueId": field_map.get(start_field_name),
|
|
4308
|
+
"endTimeQueId": field_map.get(end_field_name),
|
|
4309
|
+
"defaultTimeDimension": "week",
|
|
4310
|
+
"ganttGroupVOList": [],
|
|
4311
|
+
"ganttDependencyVO": {
|
|
4312
|
+
"dependencyQueId": None,
|
|
4313
|
+
"predecessorTaskQueId": None,
|
|
4314
|
+
"startEndOptionId": None,
|
|
4315
|
+
"startStartOptionId": None,
|
|
4316
|
+
"endEndOptionId": None,
|
|
4317
|
+
"endStartOptionId": None,
|
|
4318
|
+
},
|
|
4319
|
+
"ganttAutoCalibrationVO": {
|
|
4320
|
+
"autoCalibrationRuleVO": {
|
|
4321
|
+
"startStartBegin": False,
|
|
4322
|
+
"startEndBegin": False,
|
|
4323
|
+
"startEndFinish": False,
|
|
4324
|
+
"endStartBegin": True,
|
|
4325
|
+
"endStartFinish": True,
|
|
4326
|
+
"endEndFinish": False,
|
|
4327
|
+
},
|
|
4328
|
+
"beingAutoCalibration": False,
|
|
4329
|
+
"userAutoCalibration": False,
|
|
4330
|
+
},
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
|
|
4334
|
+
def _build_view_filter_groups(
|
|
4335
|
+
*,
|
|
4336
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
4337
|
+
filters: list[Any],
|
|
4338
|
+
) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
|
|
4339
|
+
translated_rules: list[dict[str, Any]] = []
|
|
4340
|
+
issues: list[dict[str, Any]] = []
|
|
4341
|
+
for raw_rule in filters:
|
|
4342
|
+
if hasattr(raw_rule, "model_dump"):
|
|
4343
|
+
raw_rule = raw_rule.model_dump(mode="json")
|
|
4344
|
+
if not isinstance(raw_rule, dict):
|
|
4345
|
+
continue
|
|
4346
|
+
field_name = str(raw_rule.get("field_name") or "").strip()
|
|
4347
|
+
field = current_fields_by_name.get(field_name)
|
|
4348
|
+
if field is None:
|
|
4349
|
+
issues.append(
|
|
4350
|
+
{
|
|
4351
|
+
"error_code": "UNKNOWN_VIEW_FIELD",
|
|
4352
|
+
"missing_fields": [field_name] if field_name else [],
|
|
4353
|
+
"reason_path": "filters[].field_name",
|
|
4354
|
+
}
|
|
4355
|
+
)
|
|
4356
|
+
continue
|
|
4357
|
+
translated_rules.append(_translate_view_filter_rule(field=field, rule=raw_rule))
|
|
4358
|
+
return ([translated_rules] if translated_rules else []), issues
|
|
4359
|
+
|
|
4360
|
+
|
|
4361
|
+
def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]:
|
|
4362
|
+
operator = str(rule.get("operator") or "").strip().lower()
|
|
4363
|
+
values = list(rule.get("values") or [])
|
|
4364
|
+
field_type = str(field.get("type") or FieldType.text.value)
|
|
4365
|
+
que_id = _coerce_positive_int(field.get("que_id")) or 0
|
|
4366
|
+
que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
|
|
4367
|
+
payload: dict[str, Any] = {
|
|
4368
|
+
"queId": que_id,
|
|
4369
|
+
"queTitle": str(field.get("name") or ""),
|
|
4370
|
+
"queType": que_type,
|
|
4371
|
+
"matchType": MATCH_TYPE_ACCURACY,
|
|
4372
|
+
"judgeValueDetails": _build_view_filter_value_details(values),
|
|
4373
|
+
}
|
|
4374
|
+
if operator == "eq":
|
|
4375
|
+
payload["judgeType"] = JUDGE_EQUAL
|
|
4376
|
+
payload["judgeValues"] = [_stringify_condition_value(values[0])] if values else []
|
|
4377
|
+
elif operator == "neq":
|
|
4378
|
+
payload["judgeType"] = JUDGE_UNEQUAL
|
|
4379
|
+
payload["judgeValues"] = [_stringify_condition_value(values[0])] if values else []
|
|
4380
|
+
elif operator == "in":
|
|
4381
|
+
payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
|
|
4382
|
+
payload["judgeValues"] = [_stringify_condition_value(value) for value in values]
|
|
4383
|
+
elif operator == "contains":
|
|
4384
|
+
payload["judgeType"] = JUDGE_FUZZY_MATCH
|
|
4385
|
+
payload["judgeValues"] = [_stringify_condition_value(values[0])] if values else []
|
|
4386
|
+
elif operator == "gte":
|
|
4387
|
+
payload["judgeType"] = JUDGE_GREATER_OR_EQUAL
|
|
4388
|
+
payload["judgeValues"] = [_stringify_condition_value(values[0])] if values else []
|
|
4389
|
+
elif operator == "lte":
|
|
4390
|
+
payload["judgeType"] = JUDGE_LESS_OR_EQUAL
|
|
4391
|
+
payload["judgeValues"] = [_stringify_condition_value(values[0])] if values else []
|
|
4392
|
+
elif operator == "is_empty":
|
|
4393
|
+
payload["judgeType"] = JUDGE_EQUAL
|
|
4394
|
+
payload["judgeValues"] = []
|
|
4395
|
+
elif operator == "not_empty":
|
|
4396
|
+
payload["judgeType"] = JUDGE_UNEQUAL
|
|
4397
|
+
payload["judgeValues"] = []
|
|
2584
4398
|
return payload
|
|
2585
4399
|
|
|
2586
4400
|
|
|
4401
|
+
def _build_view_filter_value_details(values: list[Any]) -> list[dict[str, Any]]:
|
|
4402
|
+
details: list[dict[str, Any]] = []
|
|
4403
|
+
for value in values:
|
|
4404
|
+
if isinstance(value, dict):
|
|
4405
|
+
item_id = value.get("id")
|
|
4406
|
+
if item_id is None:
|
|
4407
|
+
continue
|
|
4408
|
+
item_value = value.get("value")
|
|
4409
|
+
details.append({"id": item_id, "value": item_value if item_value is not None else str(item_id)})
|
|
4410
|
+
elif isinstance(value, int):
|
|
4411
|
+
details.append({"id": value, "value": str(value)})
|
|
4412
|
+
return details
|
|
4413
|
+
|
|
4414
|
+
|
|
2587
4415
|
def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
|
|
2588
4416
|
preferred_names = {"status", "状态", "订单状态", "审批状态", "流程状态"}
|
|
2589
4417
|
for field in fields:
|
|
@@ -2615,6 +4443,7 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
|
|
|
2615
4443
|
request_id = first_error.get("request_id")
|
|
2616
4444
|
backend_code = first_error.get("backend_code")
|
|
2617
4445
|
http_status = first_error.get("http_status")
|
|
4446
|
+
lowered_detail = detail_text.lower()
|
|
2618
4447
|
if "must declare status field" in detail_text:
|
|
2619
4448
|
return _failed(
|
|
2620
4449
|
"STATUS_FIELD_REQUIRED",
|
|
@@ -2647,14 +4476,45 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
|
|
|
2647
4476
|
backend_code=backend_code,
|
|
2648
4477
|
http_status=http_status,
|
|
2649
4478
|
)
|
|
4479
|
+
if (
|
|
4480
|
+
"run solution_build_app first" in lowered_detail
|
|
4481
|
+
or "run solution_build_app_flow first" in lowered_detail
|
|
4482
|
+
or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
|
|
4483
|
+
):
|
|
4484
|
+
return _failed(
|
|
4485
|
+
"FLOW_STAGE_CONTEXT_MISSING",
|
|
4486
|
+
"workflow apply lost the app context required by the internal flow builder",
|
|
4487
|
+
details={
|
|
4488
|
+
"app_key": app_key,
|
|
4489
|
+
"entity_id": entity.get("entity_id"),
|
|
4490
|
+
"existing_fields": entity.get("fields") or [],
|
|
4491
|
+
"internal_detail": detail_text,
|
|
4492
|
+
"stage_result": public_stage_result,
|
|
4493
|
+
},
|
|
4494
|
+
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
|
|
4495
|
+
request_id=request_id,
|
|
4496
|
+
backend_code=backend_code,
|
|
4497
|
+
http_status=http_status,
|
|
4498
|
+
)
|
|
4499
|
+
message = detail_text or "failed to apply workflow patch"
|
|
4500
|
+
details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
|
|
4501
|
+
public_http_status = http_status
|
|
4502
|
+
if http_status == 404:
|
|
4503
|
+
message = "workflow write route is unavailable for this app in the current route"
|
|
4504
|
+
details["transport_error"] = {
|
|
4505
|
+
"http_status": http_status,
|
|
4506
|
+
"backend_code": backend_code,
|
|
4507
|
+
"category": "http",
|
|
4508
|
+
}
|
|
4509
|
+
public_http_status = None
|
|
2650
4510
|
return _failed(
|
|
2651
4511
|
stage_error_code,
|
|
2652
|
-
|
|
2653
|
-
details=
|
|
4512
|
+
message,
|
|
4513
|
+
details=details,
|
|
2654
4514
|
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2655
4515
|
request_id=request_id,
|
|
2656
4516
|
backend_code=backend_code,
|
|
2657
|
-
http_status=
|
|
4517
|
+
http_status=public_http_status,
|
|
2658
4518
|
)
|
|
2659
4519
|
|
|
2660
4520
|
|