@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (32) hide show
  1. package/README.md +12 -2
  2. package/npm/lib/runtime.mjs +37 -0
  3. package/npm/scripts/postinstall.mjs +5 -1
  4. package/package.json +3 -2
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +230 -0
  7. package/skills/qingflow-app-user/agents/openai.yaml +4 -0
  8. package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
  9. package/skills/qingflow-app-user/references/environments.md +63 -0
  10. package/skills/qingflow-app-user/references/record-patterns.md +110 -0
  11. package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
  12. package/skills/qingflow-record-analysis/SKILL.md +253 -0
  13. package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
  14. package/skills/qingflow-record-analysis/references/analysis-gotchas.md +141 -0
  15. package/skills/qingflow-record-analysis/references/analysis-patterns.md +113 -0
  16. package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
  17. package/src/qingflow_mcp/__init__.py +1 -1
  18. package/src/qingflow_mcp/builder_facade/models.py +294 -1
  19. package/src/qingflow_mcp/builder_facade/service.py +2727 -235
  20. package/src/qingflow_mcp/server.py +7 -5
  21. package/src/qingflow_mcp/server_app_builder.py +80 -4
  22. package/src/qingflow_mcp/server_app_user.py +8 -182
  23. package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
  24. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
  25. package/src/qingflow_mcp/solution/executor.py +34 -7
  26. package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
  27. package/src/qingflow_mcp/tools/app_tools.py +1 -2
  28. package/src/qingflow_mcp/tools/approval_tools.py +357 -75
  29. package/src/qingflow_mcp/tools/directory_tools.py +158 -28
  30. package/src/qingflow_mcp/tools/record_tools.py +1954 -973
  31. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  32. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -4,6 +4,7 @@ from copy import deepcopy
4
4
  from dataclasses import dataclass
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import tempfile
8
9
  from typing import Any
9
10
  from uuid import uuid4
@@ -17,7 +18,9 @@ from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
17
18
  from ..solution.executor import extract_field_map, _build_viewgraph_questions
18
19
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
19
20
  from ..tools.app_tools import AppTools
21
+ from ..tools.directory_tools import DirectoryTools
20
22
  from ..tools.package_tools import PackageTools
23
+ from ..tools.role_tools import RoleTools
21
24
  from ..tools.solution_tools import SolutionTools
22
25
  from ..tools.view_tools import ViewTools
23
26
  from ..tools.workflow_tools import WorkflowTools
@@ -32,16 +35,20 @@ from .models import (
32
35
  FieldSelector,
33
36
  FieldUpdatePatch,
34
37
  FlowPlanRequest,
38
+ FlowAssigneePatch,
35
39
  LayoutApplyMode,
36
40
  LayoutPlanRequest,
37
41
  LayoutSectionPatch,
38
42
  LayoutPreset,
39
43
  PublicFieldType,
44
+ PublicViewType,
40
45
  SchemaPlanRequest,
41
46
  ViewUpsertPatch,
47
+ ViewFilterOperator,
42
48
  ViewsPlanRequest,
43
49
  ViewsPreset,
44
50
  FlowPreset,
51
+ FlowNodePermissionsPatch,
45
52
  )
46
53
 
47
54
 
@@ -63,6 +70,41 @@ QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
63
70
  25: FieldType.relation.value,
64
71
  }
65
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
+
66
108
 
67
109
  @dataclass(slots=True)
68
110
  class ResolvedApp:
@@ -79,12 +121,16 @@ class AiBuilderFacade:
79
121
  packages: PackageTools,
80
122
  views: ViewTools,
81
123
  workflows: WorkflowTools,
124
+ roles: RoleTools,
125
+ directory: DirectoryTools,
82
126
  solutions: SolutionTools,
83
127
  ) -> None:
84
128
  self.apps = apps
85
129
  self.packages = packages
86
130
  self.views = views
87
131
  self.workflows = workflows
132
+ self.roles = roles
133
+ self.directory = directory
88
134
  self.solutions = solutions
89
135
 
90
136
  def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
@@ -135,6 +181,75 @@ class AiBuilderFacade:
135
181
  "match_mode": "exact",
136
182
  }
137
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
+
138
253
  def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
139
254
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
140
255
  return {
@@ -157,6 +272,465 @@ class AiBuilderFacade:
157
272
  "retried": bool(listed.get("retried", False)),
158
273
  }
159
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
+
682
+ def _canonicalize_flow_nodes_for_public_output(self, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
683
+ public_nodes: list[dict[str, Any]] = []
684
+ for node in nodes:
685
+ if not isinstance(node, dict):
686
+ continue
687
+ payload = deepcopy(node)
688
+ assignees = payload.get("assignees") if isinstance(payload.get("assignees"), dict) else {}
689
+ permissions = payload.get("permissions") if isinstance(payload.get("permissions"), dict) else {}
690
+ public_assignees: dict[str, Any] = {}
691
+ role_ids = [
692
+ role_id
693
+ for role_id in (
694
+ _coerce_positive_int(entry.get("roleId"))
695
+ for entry in (assignees.get("role_entries") or [])
696
+ if isinstance(entry, dict)
697
+ )
698
+ if role_id is not None
699
+ ]
700
+ member_uids = [
701
+ member_uid
702
+ for member_uid in (_coerce_positive_int(value) for value in (assignees.get("member_uids") or []))
703
+ if member_uid is not None
704
+ ]
705
+ if role_ids:
706
+ public_assignees["role_ids"] = role_ids
707
+ if member_uids:
708
+ public_assignees["member_uids"] = member_uids
709
+ if bool(assignees.get("include_sub_departs")):
710
+ public_assignees["include_sub_departs"] = True
711
+ public_permissions: dict[str, Any] = {}
712
+ editable_fields = [str(name) for name in (permissions.get("editable_fields") or []) if str(name or "").strip()]
713
+ if editable_fields:
714
+ public_permissions["editable_fields"] = editable_fields
715
+ config_payload = payload.get("config") if isinstance(payload.get("config"), dict) else {}
716
+ if isinstance(config_payload, dict):
717
+ config_payload = deepcopy(config_payload)
718
+ config_payload.pop("conditionFormatMatrix", None)
719
+ if public_assignees:
720
+ payload["assignees"] = public_assignees
721
+ else:
722
+ payload.pop("assignees", None)
723
+ if public_permissions:
724
+ payload["permissions"] = public_permissions
725
+ else:
726
+ payload.pop("permissions", None)
727
+ if config_payload:
728
+ payload["config"] = config_payload
729
+ else:
730
+ payload.pop("config", None)
731
+ public_nodes.append(payload)
732
+ return public_nodes
733
+
160
734
  def package_attach_app(
161
735
  self,
162
736
  *,
@@ -210,6 +784,115 @@ class AiBuilderFacade:
210
784
  "attached": attached,
211
785
  }
212
786
 
787
+ def app_release_edit_lock_if_mine(
788
+ self,
789
+ *,
790
+ profile: str,
791
+ app_key: str,
792
+ lock_owner_email: str = "",
793
+ lock_owner_name: str = "",
794
+ ) -> JSONObject:
795
+ normalized_args = {
796
+ "app_key": app_key,
797
+ "lock_owner_email": lock_owner_email,
798
+ "lock_owner_name": lock_owner_name,
799
+ }
800
+ session_profile = self.apps.sessions.get_profile(profile)
801
+ if session_profile is None:
802
+ return _failed(
803
+ "AUTH_REQUIRED",
804
+ "auth profile is required before releasing an app edit lock",
805
+ normalized_args=normalized_args,
806
+ recoverable=False,
807
+ suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
808
+ )
809
+ identity = self._resolve_current_user_identity(profile=profile)
810
+ current_email = str(identity.get("email") or "").strip().lower()
811
+ current_name = str(identity.get("nick_name") or "").strip()
812
+ requested_owner_email = str(lock_owner_email or "").strip().lower()
813
+ requested_owner_name = str(lock_owner_name or "").strip()
814
+ if not requested_owner_email and not requested_owner_name:
815
+ return _failed(
816
+ "EDIT_LOCK_OWNER_UNKNOWN",
817
+ "lock owner could not be verified; refuse to release edit lock blindly",
818
+ normalized_args=normalized_args,
819
+ recoverable=False,
820
+ details={
821
+ "current_user_email": identity.get("email"),
822
+ "current_user_name": identity.get("nick_name"),
823
+ },
824
+ suggested_next_call=None,
825
+ )
826
+ owner_matches = True
827
+ if requested_owner_email:
828
+ owner_matches = bool(current_email) and current_email == requested_owner_email
829
+ elif requested_owner_name:
830
+ owner_matches = bool(current_name) and current_name == requested_owner_name
831
+ if not owner_matches:
832
+ return _failed(
833
+ "EDIT_LOCK_HELD_BY_OTHER_USER",
834
+ "edit lock is owned by another user; refusing to release it",
835
+ normalized_args=normalized_args,
836
+ recoverable=False,
837
+ details={
838
+ "lock_owner_email": requested_owner_email or None,
839
+ "lock_owner_name": requested_owner_name or None,
840
+ "current_user_email": identity.get("email"),
841
+ "current_user_name": identity.get("nick_name"),
842
+ },
843
+ suggested_next_call=None,
844
+ )
845
+ try:
846
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
847
+ except (QingflowApiError, RuntimeError) as error:
848
+ api_error = _coerce_api_error(error)
849
+ return _failed_from_api_error(
850
+ "EDIT_LOCK_RELEASE_FAILED",
851
+ api_error,
852
+ normalized_args=normalized_args,
853
+ details={"app_key": app_key},
854
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
855
+ )
856
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
857
+ try:
858
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
859
+ except (QingflowApiError, RuntimeError) as error:
860
+ api_error = _coerce_api_error(error)
861
+ return _failed_from_api_error(
862
+ "EDIT_LOCK_RELEASE_FAILED",
863
+ api_error,
864
+ normalized_args=normalized_args,
865
+ details={
866
+ "app_key": app_key,
867
+ "edit_version_no": edit_version_no,
868
+ "lock_owner_email": requested_owner_email or None,
869
+ "lock_owner_name": requested_owner_name or None,
870
+ },
871
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
872
+ )
873
+ return {
874
+ "status": "success",
875
+ "error_code": None,
876
+ "recoverable": False,
877
+ "message": "released app edit lock owned by current user",
878
+ "normalized_args": normalized_args,
879
+ "missing_fields": [],
880
+ "allowed_values": {},
881
+ "details": {
882
+ "lock_owner_email": requested_owner_email or None,
883
+ "lock_owner_name": requested_owner_name or None,
884
+ "current_user_email": identity.get("email"),
885
+ "current_user_name": identity.get("nick_name"),
886
+ "edit_version_no": edit_version_no,
887
+ },
888
+ "request_id": None,
889
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
890
+ "noop": False,
891
+ "verification": {"released": True},
892
+ "app_key": app_key,
893
+ "released": True,
894
+ }
895
+
213
896
  def app_resolve(
214
897
  self,
215
898
  *,
@@ -221,8 +904,14 @@ class AiBuilderFacade:
221
904
  if app_key:
222
905
  try:
223
906
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
224
- except RuntimeError as exc:
225
- return _failed("APP_NOT_FOUND", f"failed to resolve app_key '{app_key}'", details={"error": str(exc)})
907
+ except (QingflowApiError, RuntimeError) as exc:
908
+ api_error = _coerce_api_error(exc)
909
+ return _failed_from_api_error(
910
+ "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
911
+ api_error,
912
+ details={"app_key": app_key},
913
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
914
+ )
226
915
  result = base.get("result") if isinstance(base.get("result"), dict) else {}
227
916
  return {
228
917
  "status": "success",
@@ -299,8 +988,30 @@ class AiBuilderFacade:
299
988
  }
300
989
 
301
990
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
302
- state = self._load_app_state(profile=profile, app_key=app_key)
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
+ "APP_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_resolve", "arguments": {"profile": profile, "app_key": app_key}},
1001
+ )
1002
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
1003
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
303
1004
  parsed = state["parsed"]
1005
+ verification_hints = _build_verification_hints(
1006
+ tag_ids=_coerce_int_list(state["base"].get("tagIds")),
1007
+ fields=parsed["fields"],
1008
+ layout=parsed["layout"],
1009
+ views=_summarize_views(views),
1010
+ )
1011
+ if views_unavailable:
1012
+ verification_hints.append("views_read_unavailable")
1013
+ if workflow_unavailable:
1014
+ verification_hints.append("workflow_read_unavailable")
304
1015
  response = AppReadSummaryResponse(
305
1016
  app_key=app_key,
306
1017
  title=state["base"].get("formTitle"),
@@ -308,14 +1019,9 @@ class AiBuilderFacade:
308
1019
  publish_status=state["base"].get("appPublishStatus"),
309
1020
  field_count=len(parsed["fields"]),
310
1021
  layout_section_count=len(parsed["layout"].get("sections", [])),
311
- view_count=len(_summarize_views(state["views"])),
312
- workflow_enabled=bool(state["workflow"]),
313
- verification_hints=_build_verification_hints(
314
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
315
- fields=parsed["fields"],
316
- layout=parsed["layout"],
317
- views=_summarize_views(state["views"]),
318
- ),
1022
+ view_count=len(_summarize_views(views)),
1023
+ workflow_enabled=bool(workflow),
1024
+ verification_hints=verification_hints,
319
1025
  )
320
1026
  return {
321
1027
  "status": "success",
@@ -334,7 +1040,17 @@ class AiBuilderFacade:
334
1040
  }
335
1041
 
336
1042
  def app_read_fields(self, *, profile: str, app_key: str) -> JSONObject:
337
- state = self._load_app_state(profile=profile, app_key=app_key)
1043
+ try:
1044
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
1045
+ except (QingflowApiError, RuntimeError) as error:
1046
+ api_error = _coerce_api_error(error)
1047
+ return _failed_from_api_error(
1048
+ "FIELDS_READ_FAILED",
1049
+ api_error,
1050
+ normalized_args={"app_key": app_key},
1051
+ details={"app_key": app_key},
1052
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1053
+ )
338
1054
  parsed = state["parsed"]
339
1055
  response = AppFieldsReadResponse(
340
1056
  app_key=app_key,
@@ -368,7 +1084,17 @@ class AiBuilderFacade:
368
1084
  }
369
1085
 
370
1086
  def app_read_layout_summary(self, *, profile: str, app_key: str) -> JSONObject:
371
- state = self._load_app_state(profile=profile, app_key=app_key)
1087
+ try:
1088
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
1089
+ except (QingflowApiError, RuntimeError) as error:
1090
+ api_error = _coerce_api_error(error)
1091
+ return _failed_from_api_error(
1092
+ "LAYOUT_READ_FAILED",
1093
+ api_error,
1094
+ normalized_args={"app_key": app_key},
1095
+ details={"app_key": app_key},
1096
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1097
+ )
372
1098
  parsed = state["parsed"]
373
1099
  layout = parsed["layout"]
374
1100
  response = AppLayoutReadResponse(
@@ -394,10 +1120,20 @@ class AiBuilderFacade:
394
1120
  }
395
1121
 
396
1122
  def app_read_views_summary(self, *, profile: str, app_key: str) -> JSONObject:
397
- state = self._load_app_state(profile=profile, app_key=app_key)
1123
+ try:
1124
+ views, _ = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
1125
+ except (QingflowApiError, RuntimeError) as error:
1126
+ api_error = _coerce_api_error(error)
1127
+ return _failed_from_api_error(
1128
+ "VIEWS_READ_FAILED",
1129
+ api_error,
1130
+ normalized_args={"app_key": app_key},
1131
+ details={"app_key": app_key},
1132
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1133
+ )
398
1134
  response = AppViewsReadResponse(
399
1135
  app_key=app_key,
400
- views=_summarize_views(state["views"]),
1136
+ views=_summarize_views(views),
401
1137
  )
402
1138
  return {
403
1139
  "status": "success",
@@ -416,11 +1152,21 @@ class AiBuilderFacade:
416
1152
  }
417
1153
 
418
1154
  def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
419
- state = self._load_app_state(profile=profile, app_key=app_key)
1155
+ try:
1156
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
1157
+ except (QingflowApiError, RuntimeError) as error:
1158
+ api_error = _coerce_api_error(error)
1159
+ return _failed_from_api_error(
1160
+ "FLOW_READ_FAILED",
1161
+ api_error,
1162
+ normalized_args={"app_key": app_key},
1163
+ details={"app_key": app_key},
1164
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1165
+ )
420
1166
  response = AppFlowReadResponse(
421
1167
  app_key=app_key,
422
- enabled=bool(state["workflow"]),
423
- nodes=_summarize_workflow_nodes(state["workflow"]),
1168
+ enabled=bool(workflow),
1169
+ nodes=_summarize_workflow_nodes(workflow),
424
1170
  transitions=[],
425
1171
  )
426
1172
  return {
@@ -435,7 +1181,7 @@ class AiBuilderFacade:
435
1181
  "request_id": None,
436
1182
  "suggested_next_call": None,
437
1183
  "noop": False,
438
- "verification": {"app_exists": True},
1184
+ "verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
439
1185
  **response.model_dump(mode="json"),
440
1186
  }
441
1187
 
@@ -453,7 +1199,11 @@ class AiBuilderFacade:
453
1199
  return target
454
1200
  current_fields: list[dict[str, Any]] = []
455
1201
  if not bool(target.get("would_create")):
456
- current_fields = self.app_read_fields(profile=profile, app_key=str(target["app_key"])).get("fields", [])
1202
+ fields_result = self.app_read_fields(profile=profile, app_key=str(target["app_key"]))
1203
+ if fields_result.get("status") == "failed":
1204
+ fields_result.setdefault("normalized_args", normalized_args)
1205
+ return fields_result
1206
+ current_fields = fields_result.get("fields", [])
457
1207
  current_by_name = {str(field.get("name") or ""): field for field in current_fields}
458
1208
  blocking_issues: list[dict[str, Any]] = []
459
1209
  preview_added: list[str] = []
@@ -511,11 +1261,31 @@ class AiBuilderFacade:
511
1261
 
512
1262
  def app_layout_plan(self, *, profile: str, request: LayoutPlanRequest) -> JSONObject:
513
1263
  read_fields = self.app_read_fields(profile=profile, app_key=request.app_key)
514
- current_names = [str(field.get("name") or "") for field in read_fields.get("fields", []) if field.get("name")]
1264
+ if read_fields.get("status") == "failed":
1265
+ return read_fields
1266
+ current_fields = [field for field in read_fields.get("fields", []) if isinstance(field, dict)]
1267
+ current_names = [str(field.get("name") or "") for field in current_fields if field.get("name")]
515
1268
  current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
1269
+ if current_layout.get("status") == "failed":
1270
+ return current_layout
516
1271
  requested_sections = [section.model_dump(mode="json") for section in request.sections]
517
1272
  if request.preset is not None:
518
1273
  requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
1274
+ else:
1275
+ requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
1276
+ if missing_selectors:
1277
+ return _failed(
1278
+ "UNKNOWN_LAYOUT_FIELD",
1279
+ "layout references unknown field selectors",
1280
+ normalized_args={
1281
+ "app_key": request.app_key,
1282
+ "mode": request.mode.value,
1283
+ "sections": requested_sections,
1284
+ },
1285
+ details={"unknown_selectors": missing_selectors},
1286
+ missing_fields=[str(item) for item in missing_selectors],
1287
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": request.app_key}},
1288
+ )
519
1289
  merged = _merge_layout(
520
1290
  current_layout={
521
1291
  "root_rows": [],
@@ -574,10 +1344,66 @@ class AiBuilderFacade:
574
1344
  nodes = [node.model_dump(mode="json") for node in request.nodes]
575
1345
  transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
576
1346
  if request.preset is not None:
577
- nodes, transitions = _build_flow_preset(request.preset)
578
- current_fields = self.app_read_fields(profile=profile, app_key=request.app_key).get("fields", [])
1347
+ preset_nodes, preset_transitions = _build_flow_preset(request.preset)
1348
+ nodes, transitions = _merge_flow_graph(
1349
+ base_nodes=preset_nodes,
1350
+ base_transitions=preset_transitions,
1351
+ override_nodes=nodes,
1352
+ override_transitions=transitions,
1353
+ )
1354
+ fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
1355
+ if fields_result.get("status") == "failed":
1356
+ return fields_result
1357
+ current_fields = fields_result.get("fields", [])
1358
+ normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
1359
+ public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
1360
+ if resolution_issues:
1361
+ first_issue = resolution_issues[0]
1362
+ suggested_call = None
1363
+ if first_issue.get("kind", "").startswith("role"):
1364
+ suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
1365
+ elif first_issue.get("kind", "").startswith("member"):
1366
+ suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
1367
+ elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
1368
+ suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": request.app_key}}
1369
+ return _failed(
1370
+ first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
1371
+ "workflow contains unresolved assignees or field permissions",
1372
+ normalized_args={
1373
+ "app_key": request.app_key,
1374
+ "mode": str(request.mode or "replace"),
1375
+ "preset": request.preset.value if request.preset else None,
1376
+ "nodes": public_nodes,
1377
+ "transitions": transitions,
1378
+ },
1379
+ details={"issues": resolution_issues},
1380
+ suggested_next_call=suggested_call,
1381
+ )
579
1382
  status_field_present = _infer_status_field_id(current_fields) is not None
580
- node_types = {str(node.get("type") or "") for node in nodes}
1383
+ node_types = {str(node.get("type") or "") for node in normalized_nodes}
1384
+ assignee_required_nodes = [
1385
+ node.get("id")
1386
+ for node in normalized_nodes
1387
+ if str(node.get("type") or "") in {"approve", "fill", "copy"}
1388
+ and not (
1389
+ (node.get("assignees") or {}).get("role_entries")
1390
+ or (node.get("assignees") or {}).get("member_uids")
1391
+ )
1392
+ ]
1393
+ if assignee_required_nodes:
1394
+ return _failed(
1395
+ "FLOW_ASSIGNEE_REQUIRED",
1396
+ "workflow approval/fill/copy nodes must declare at least one role or member assignee",
1397
+ normalized_args={
1398
+ "app_key": request.app_key,
1399
+ "mode": str(request.mode or "replace"),
1400
+ "preset": request.preset.value if request.preset else None,
1401
+ "nodes": public_nodes,
1402
+ "transitions": transitions,
1403
+ },
1404
+ details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
1405
+ suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
1406
+ )
581
1407
  if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
582
1408
  return _failed(
583
1409
  "FLOW_DEPENDENCY_MISSING",
@@ -585,7 +1411,7 @@ class AiBuilderFacade:
585
1411
  normalized_args={
586
1412
  "app_key": request.app_key,
587
1413
  "mode": str(request.mode or "replace"),
588
- "nodes": nodes,
1414
+ "nodes": public_nodes,
589
1415
  "transitions": transitions,
590
1416
  },
591
1417
  details={"missing_dependencies": ["status field"]},
@@ -602,12 +1428,12 @@ class AiBuilderFacade:
602
1428
  },
603
1429
  },
604
1430
  )
605
- workflow = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
1431
+ workflow = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
606
1432
  if workflow.get("status") == "failed":
607
1433
  workflow["normalized_args"] = {
608
1434
  "app_key": request.app_key,
609
1435
  "mode": str(request.mode or "replace"),
610
- "nodes": nodes,
1436
+ "nodes": public_nodes,
611
1437
  "transitions": transitions,
612
1438
  }
613
1439
  workflow["suggested_next_call"] = {
@@ -616,7 +1442,7 @@ class AiBuilderFacade:
616
1442
  "profile": profile,
617
1443
  "app_key": request.app_key,
618
1444
  "mode": "replace",
619
- "nodes": nodes,
1445
+ "nodes": public_nodes,
620
1446
  "transitions": transitions,
621
1447
  },
622
1448
  }
@@ -624,7 +1450,7 @@ class AiBuilderFacade:
624
1450
  normalized_args = {
625
1451
  "app_key": request.app_key,
626
1452
  "mode": str(request.mode or "replace"),
627
- "nodes": nodes,
1453
+ "nodes": public_nodes,
628
1454
  "transitions": transitions,
629
1455
  }
630
1456
  return {
@@ -645,8 +1471,16 @@ class AiBuilderFacade:
645
1471
  }
646
1472
 
647
1473
  def app_views_plan(self, *, profile: str, request: ViewsPlanRequest) -> JSONObject:
648
- current_fields = self.app_read_fields(profile=profile, app_key=request.app_key).get("fields", [])
1474
+ fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
1475
+ if fields_result.get("status") == "failed":
1476
+ return fields_result
1477
+ current_fields = fields_result.get("fields", [])
649
1478
  field_names = {str(field.get("name") or "") for field in current_fields}
1479
+ current_fields_by_name = {
1480
+ str(field.get("name") or ""): field
1481
+ for field in current_fields
1482
+ if isinstance(field, dict) and str(field.get("name") or "")
1483
+ }
650
1484
  upsert_views = [view.model_dump(mode="json") for view in request.upsert_views]
651
1485
  if request.preset is not None:
652
1486
  upsert_views = _build_views_preset(request.preset, list(field_names))
@@ -659,6 +1493,31 @@ class AiBuilderFacade:
659
1493
  group_by = patch.get("group_by")
660
1494
  if group_by and group_by not in field_names:
661
1495
  blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": [group_by]})
1496
+ start_field = str(patch.get("start_field") or "").strip()
1497
+ end_field = str(patch.get("end_field") or "").strip()
1498
+ title_field = str(patch.get("title_field") or "").strip()
1499
+ if patch.get("type") == "gantt":
1500
+ missing_required = []
1501
+ if not start_field:
1502
+ missing_required.append("start_field")
1503
+ if not end_field:
1504
+ missing_required.append("end_field")
1505
+ if missing_required:
1506
+ blocking_issues.append({"error_code": "INVALID_GANTT_CONFIG", "view_name": patch.get("name"), "missing_fields": missing_required})
1507
+ missing_gantt_fields = [name for name in (start_field, end_field, title_field) if name and name not in field_names]
1508
+ if missing_gantt_fields:
1509
+ blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_gantt_fields})
1510
+ translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.get("filters") or [])
1511
+ if filter_issues:
1512
+ blocking_issues.extend(
1513
+ {
1514
+ **issue,
1515
+ "view_name": patch.get("name"),
1516
+ }
1517
+ for issue in filter_issues
1518
+ )
1519
+ if translated_filters:
1520
+ patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
662
1521
  normalized_args = {
663
1522
  "app_key": request.app_key,
664
1523
  "upsert_views": upsert_views,
@@ -671,7 +1530,11 @@ class AiBuilderFacade:
671
1530
  "message": "view plan has blocking issues" if blocking_issues else "planned view patch",
672
1531
  "normalized_args": normalized_args,
673
1532
  "missing_fields": [],
674
- "allowed_values": {"view_types": ["table", "card", "board"], "presets": [preset.value for preset in ViewsPreset]},
1533
+ "allowed_values": {
1534
+ "view_types": [member.value for member in PublicViewType],
1535
+ "presets": [preset.value for preset in ViewsPreset],
1536
+ "view.filter.operator": [member.value for member in ViewFilterOperator],
1537
+ },
675
1538
  "details": {},
676
1539
  "request_id": None,
677
1540
  "views_diff_preview": {
@@ -731,6 +1594,7 @@ class AiBuilderFacade:
731
1594
  package_tag_id: int | None = None,
732
1595
  app_name: str = "",
733
1596
  create_if_missing: bool = False,
1597
+ publish: bool = True,
734
1598
  add_fields: list[FieldPatch],
735
1599
  update_fields: list[FieldUpdatePatch],
736
1600
  remove_fields: list[FieldRemovePatch],
@@ -740,6 +1604,7 @@ class AiBuilderFacade:
740
1604
  "package_tag_id": package_tag_id,
741
1605
  "app_name": app_name,
742
1606
  "create_if_missing": create_if_missing,
1607
+ "publish": publish,
743
1608
  "add_fields": [patch.model_dump(mode="json") for patch in add_fields],
744
1609
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
745
1610
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
@@ -757,19 +1622,26 @@ class AiBuilderFacade:
757
1622
  return resolved
758
1623
  target = ResolvedApp(
759
1624
  app_key=str(resolved["app_key"]),
760
- app_name=str(resolved["app_name"]),
761
- tag_ids=_coerce_int_list(resolved.get("tag_ids")),
762
- )
763
- schema = self.apps.app_get_form_schema(
764
- profile=profile,
765
- app_key=target.app_key,
766
- form_type=1,
767
- being_draft=True,
768
- being_apply=None,
769
- audit_node_id=None,
770
- include_raw=True,
1625
+ app_name=str(resolved["app_name"]),
1626
+ tag_ids=_coerce_int_list(resolved.get("tag_ids")),
771
1627
  )
772
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
1628
+ schema_readback_delayed = False
1629
+ try:
1630
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
1631
+ except (QingflowApiError, RuntimeError) as error:
1632
+ api_error = _coerce_api_error(error)
1633
+ if not bool(resolved.get("created")) or api_error.http_status != 404:
1634
+ return _failed_from_api_error(
1635
+ "SCHEMA_READBACK_FAILED",
1636
+ api_error,
1637
+ normalized_args=normalized_args,
1638
+ allowed_values={"field_types": [item.value for item in PublicFieldType]},
1639
+ details={"app_key": target.app_key},
1640
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
1641
+ )
1642
+ schema_result = _empty_schema_result(target.app_name)
1643
+ _schema_source = "synthetic_new_app"
1644
+ schema_readback_delayed = True
773
1645
  parsed = _parse_schema(schema_result)
774
1646
  current_fields = parsed["fields"]
775
1647
  layout = parsed["layout"]
@@ -834,7 +1706,7 @@ class AiBuilderFacade:
834
1706
  if not added and not updated and not removed and not bool(resolved.get("created")):
835
1707
  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"))
836
1708
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
837
- return {
1709
+ response = {
838
1710
  "status": "success",
839
1711
  "error_code": None,
840
1712
  "recoverable": False,
@@ -854,6 +1726,7 @@ class AiBuilderFacade:
854
1726
  "tag_ids_after": tag_ids_after,
855
1727
  "package_attached": package_attached,
856
1728
  }
1729
+ return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
857
1730
 
858
1731
  payload = _build_form_payload_from_fields(
859
1732
  title=schema_result.get("formTitle") or target.app_name,
@@ -861,6 +1734,11 @@ class AiBuilderFacade:
861
1734
  fields=current_fields,
862
1735
  layout=layout,
863
1736
  )
1737
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1738
+ profile=profile,
1739
+ app_key=target.app_key,
1740
+ current_schema=schema_result,
1741
+ )
864
1742
  try:
865
1743
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
866
1744
  except (QingflowApiError, RuntimeError) as error:
@@ -876,13 +1754,8 @@ class AiBuilderFacade:
876
1754
  },
877
1755
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
878
1756
  )
879
- verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
880
- verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
881
- 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"))
882
- 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)
883
- package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
884
- return {
885
- "status": "success" if verification_ok and (package_attached is not False) else "partial_success",
1757
+ response = {
1758
+ "status": "success",
886
1759
  "error_code": None,
887
1760
  "recoverable": False,
888
1761
  "message": "applied schema patch",
@@ -891,21 +1764,11 @@ class AiBuilderFacade:
891
1764
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
892
1765
  "details": {},
893
1766
  "request_id": None,
894
- "suggested_next_call": None
895
- if package_attached is not False
896
- else {
897
- "tool_name": "package_attach_app",
898
- "arguments": {
899
- "profile": profile,
900
- "tag_id": package_tag_id,
901
- "app_key": target.app_key,
902
- "app_title": app_name or target.app_name,
903
- },
904
- },
1767
+ "suggested_next_call": None,
905
1768
  "noop": False,
906
1769
  "verification": {
907
- "fields_verified": verification_ok,
908
- "package_attached": package_attached,
1770
+ "fields_verified": False,
1771
+ "package_attached": None,
909
1772
  },
910
1773
  "app_key": target.app_key,
911
1774
  "created": bool(resolved.get("created")),
@@ -914,10 +1777,71 @@ class AiBuilderFacade:
914
1777
  "updated": updated,
915
1778
  "removed": removed,
916
1779
  },
917
- "verified": verification_ok,
918
- "tag_ids_after": tag_ids_after,
919
- "package_attached": package_attached,
1780
+ "verified": False,
1781
+ "tag_ids_after": [],
1782
+ "package_attached": None,
920
1783
  }
1784
+ if schema_readback_delayed:
1785
+ response["verification"]["schema_readback_delayed"] = True
1786
+ response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
1787
+ verification_ok = False
1788
+ tag_ids_after: list[int] = []
1789
+ package_attached: bool | None = None
1790
+ verification_error: QingflowApiError | None = None
1791
+ try:
1792
+ verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
1793
+ verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
1794
+ 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)
1795
+ except (QingflowApiError, RuntimeError) as error:
1796
+ verification_error = _coerce_api_error(error)
1797
+ verification_ok = False
1798
+ try:
1799
+ base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
1800
+ tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
1801
+ package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
1802
+ except (QingflowApiError, RuntimeError) as error:
1803
+ base_error = _coerce_api_error(error)
1804
+ if verification_error is None:
1805
+ verification_error = base_error
1806
+ tag_ids_after = []
1807
+ package_attached = None if package_tag_id is None else False
1808
+ response["verification"]["fields_verified"] = verification_ok
1809
+ response["verification"]["package_attached"] = package_attached
1810
+ response["verified"] = verification_ok
1811
+ response["tag_ids_after"] = tag_ids_after
1812
+ response["package_attached"] = package_attached
1813
+ if package_attached is False:
1814
+ response["suggested_next_call"] = {
1815
+ "tool_name": "package_attach_app",
1816
+ "arguments": {
1817
+ "profile": profile,
1818
+ "tag_id": package_tag_id,
1819
+ "app_key": target.app_key,
1820
+ "app_title": app_name or target.app_name,
1821
+ },
1822
+ }
1823
+ publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
1824
+ if verification_ok and package_attached is not False and not publish_failed:
1825
+ response["status"] = "success"
1826
+ else:
1827
+ response["status"] = "partial_success"
1828
+ if verification_error is not None:
1829
+ response["recoverable"] = True
1830
+ response["error_code"] = response.get("error_code") or (
1831
+ "READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
1832
+ )
1833
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
1834
+ response["request_id"] = response.get("request_id") or verification_error.request_id
1835
+ details = response.get("details")
1836
+ if not isinstance(details, dict):
1837
+ details = {}
1838
+ response["details"] = details
1839
+ details["verification_error"] = {
1840
+ "message": verification_error.message,
1841
+ "http_status": verification_error.http_status,
1842
+ "backend_code": verification_error.backend_code,
1843
+ }
1844
+ return response
921
1845
 
922
1846
  def app_layout_apply(
923
1847
  self,
@@ -926,28 +1850,47 @@ class AiBuilderFacade:
926
1850
  app_key: str,
927
1851
  mode: LayoutApplyMode = LayoutApplyMode.merge,
928
1852
  sections: list[LayoutSectionPatch],
1853
+ publish: bool = True,
929
1854
  ) -> JSONObject:
1855
+ requested_sections = [section.model_dump(mode="json") for section in sections]
1856
+ try:
1857
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1858
+ except (QingflowApiError, RuntimeError) as error:
1859
+ api_error = _coerce_api_error(error)
1860
+ return _failed_from_api_error(
1861
+ "LAYOUT_READ_FAILED",
1862
+ api_error,
1863
+ normalized_args={
1864
+ "app_key": app_key,
1865
+ "mode": mode.value,
1866
+ "sections": requested_sections,
1867
+ "publish": publish,
1868
+ },
1869
+ details={"app_key": app_key},
1870
+ suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
1871
+ )
1872
+ parsed = _parse_schema(schema_result)
1873
+ current_fields = parsed["fields"]
1874
+ requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
930
1875
  normalized_args = {
931
1876
  "app_key": app_key,
932
1877
  "mode": mode.value,
933
- "sections": [section.model_dump(mode="json") for section in sections],
1878
+ "sections": requested_sections,
1879
+ "publish": publish,
934
1880
  }
935
- schema = self.apps.app_get_form_schema(
936
- profile=profile,
937
- app_key=app_key,
938
- form_type=1,
939
- being_draft=True,
940
- being_apply=None,
941
- audit_node_id=None,
942
- include_raw=True,
943
- )
944
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
945
- parsed = _parse_schema(schema_result)
946
- current_fields = parsed["fields"]
1881
+ if missing_selectors:
1882
+ return _failed(
1883
+ "UNKNOWN_LAYOUT_FIELD",
1884
+ "layout references unknown field selectors",
1885
+ normalized_args=normalized_args,
1886
+ details={"unknown_selectors": missing_selectors},
1887
+ missing_fields=[str(item) for item in missing_selectors],
1888
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
1889
+ )
947
1890
  fields_by_name = {field["name"]: field for field in current_fields}
948
1891
  seen: list[str] = []
949
- for section in sections:
950
- for row in section.rows:
1892
+ for section in requested_sections:
1893
+ for row in section.get("rows", []):
951
1894
  for field_name in row:
952
1895
  if field_name not in fields_by_name:
953
1896
  return _failed(
@@ -967,7 +1910,6 @@ class AiBuilderFacade:
967
1910
  )
968
1911
  seen.append(field_name)
969
1912
  expected = {field["name"] for field in current_fields}
970
- requested_sections = [section.model_dump(mode="json") for section in sections]
971
1913
  if mode == LayoutApplyMode.replace and set(seen) != expected:
972
1914
  missing = sorted(expected.difference(seen))
973
1915
  return _failed(
@@ -999,7 +1941,7 @@ class AiBuilderFacade:
999
1941
  else merged["layout"]
1000
1942
  )
1001
1943
  if _layouts_equal(parsed["layout"], target_layout):
1002
- return {
1944
+ response = {
1003
1945
  "status": "success",
1004
1946
  "error_code": None,
1005
1947
  "recoverable": False,
@@ -1022,10 +1964,16 @@ class AiBuilderFacade:
1022
1964
  },
1023
1965
  "verified": True,
1024
1966
  }
1967
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1025
1968
  payload = _build_form_payload_from_existing_schema(
1026
1969
  current_schema=schema_result,
1027
1970
  layout=target_layout,
1028
1971
  )
1972
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1973
+ profile=profile,
1974
+ app_key=app_key,
1975
+ current_schema=schema_result,
1976
+ )
1029
1977
  applied_layout = target_layout
1030
1978
  fallback_applied = None
1031
1979
  try:
@@ -1038,6 +1986,11 @@ class AiBuilderFacade:
1038
1986
  current_schema=schema_result,
1039
1987
  layout=flattened_layout,
1040
1988
  )
1989
+ fallback_payload["editVersionNo"] = self._resolve_form_edit_version(
1990
+ profile=profile,
1991
+ app_key=app_key,
1992
+ current_schema=schema_result,
1993
+ )
1041
1994
  try:
1042
1995
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
1043
1996
  applied_layout = flattened_layout
@@ -1071,8 +2024,33 @@ class AiBuilderFacade:
1071
2024
  },
1072
2025
  suggested_next_call={"tool_name": "app_layout_plan", "arguments": {"profile": profile, **normalized_args}},
1073
2026
  )
1074
- verified = self.app_read(profile=profile, app_key=app_key, include_raw=False)
1075
- return {
2027
+ verified = self.app_read_layout_summary(profile=profile, app_key=app_key)
2028
+ if verified.get("status") == "failed":
2029
+ response = {
2030
+ "status": "partial_success",
2031
+ "error_code": "LAYOUT_READBACK_PENDING",
2032
+ "recoverable": True,
2033
+ "message": "applied app layout; layout readback pending",
2034
+ "normalized_args": normalized_args,
2035
+ "missing_fields": [],
2036
+ "allowed_values": {"modes": ["merge", "replace"]},
2037
+ "details": {},
2038
+ "request_id": verified.get("request_id"),
2039
+ "suggested_next_call": {"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
2040
+ "noop": False,
2041
+ "verification": {"layout_verified": False, "layout_read_unavailable": True},
2042
+ "app_key": app_key,
2043
+ "layout_diff": {
2044
+ "mode": mode.value,
2045
+ "replaced": mode == LayoutApplyMode.replace,
2046
+ "merged": mode == LayoutApplyMode.merge,
2047
+ "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
2048
+ "fallback_applied": fallback_applied,
2049
+ },
2050
+ "verified": False,
2051
+ }
2052
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2053
+ response = {
1076
2054
  "status": "partial_success" if fallback_applied else "success",
1077
2055
  "error_code": None,
1078
2056
  "recoverable": False,
@@ -1084,7 +2062,7 @@ class AiBuilderFacade:
1084
2062
  "request_id": None,
1085
2063
  "suggested_next_call": None,
1086
2064
  "noop": False,
1087
- "verification": {"layout_verified": verified["layout"] == applied_layout},
2065
+ "verification": {"layout_verified": verified["sections"] == applied_layout.get("sections", [])},
1088
2066
  "app_key": app_key,
1089
2067
  "layout_diff": {
1090
2068
  "mode": mode.value,
@@ -1093,8 +2071,9 @@ class AiBuilderFacade:
1093
2071
  "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
1094
2072
  "fallback_applied": fallback_applied,
1095
2073
  },
1096
- "verified": verified["layout"] == applied_layout,
2074
+ "verified": verified["sections"] == applied_layout.get("sections", []),
1097
2075
  }
2076
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1098
2077
 
1099
2078
  def app_flow_apply(
1100
2079
  self,
@@ -1104,12 +2083,14 @@ class AiBuilderFacade:
1104
2083
  nodes: list[dict[str, Any]],
1105
2084
  transitions: list[dict[str, Any]],
1106
2085
  mode: str = "replace",
2086
+ publish: bool = True,
1107
2087
  ) -> JSONObject:
1108
2088
  normalized_args = {
1109
2089
  "app_key": app_key,
1110
2090
  "mode": mode,
1111
2091
  "nodes": nodes,
1112
2092
  "transitions": transitions,
2093
+ "publish": publish,
1113
2094
  }
1114
2095
  if mode != "replace":
1115
2096
  return _failed(
@@ -1119,25 +2100,65 @@ class AiBuilderFacade:
1119
2100
  allowed_values={"modes": ["replace"]},
1120
2101
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}},
1121
2102
  )
1122
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1123
- schema = self.apps.app_get_form_schema(
1124
- profile=profile,
1125
- app_key=app_key,
1126
- form_type=1,
1127
- being_draft=True,
1128
- being_apply=None,
1129
- audit_node_id=None,
1130
- include_raw=True,
1131
- ).get("result") or {}
2103
+ try:
2104
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2105
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2106
+ except (QingflowApiError, RuntimeError) as error:
2107
+ api_error = _coerce_api_error(error)
2108
+ return _failed_from_api_error(
2109
+ "FLOW_READ_FAILED",
2110
+ api_error,
2111
+ normalized_args=normalized_args,
2112
+ details={"app_key": app_key},
2113
+ suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
2114
+ )
1132
2115
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
1133
- workflow_spec = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
2116
+ current_fields = _parse_schema(schema)["fields"]
2117
+ normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
2118
+ public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
2119
+ normalized_args["nodes"] = public_nodes
2120
+ if resolution_issues:
2121
+ first_issue = resolution_issues[0]
2122
+ suggested_call = None
2123
+ if first_issue.get("kind", "").startswith("role"):
2124
+ suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
2125
+ elif first_issue.get("kind", "").startswith("member"):
2126
+ suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
2127
+ elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
2128
+ suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}}
2129
+ return _failed(
2130
+ first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
2131
+ "workflow contains unresolved assignees or field permissions",
2132
+ normalized_args=normalized_args,
2133
+ details={"issues": resolution_issues},
2134
+ suggested_next_call=suggested_call,
2135
+ )
2136
+ assignee_required_nodes = [
2137
+ node.get("id")
2138
+ for node in normalized_nodes
2139
+ if str(node.get("type") or "") in {"approve", "fill", "copy"}
2140
+ and not (
2141
+ (node.get("assignees") or {}).get("role_entries")
2142
+ or (node.get("assignees") or {}).get("member_uids")
2143
+ )
2144
+ ]
2145
+ if assignee_required_nodes:
2146
+ return _failed(
2147
+ "FLOW_ASSIGNEE_REQUIRED",
2148
+ "workflow approval/fill/copy nodes must declare at least one role or member assignee",
2149
+ normalized_args=normalized_args,
2150
+ details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
2151
+ suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
2152
+ )
2153
+ workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
1134
2154
  if workflow_spec.get("status") == "failed":
1135
2155
  workflow_spec["normalized_args"] = normalized_args
1136
2156
  workflow_spec.setdefault("request_id", None)
1137
2157
  workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1138
2158
  return workflow_spec
1139
- desired_node_count = len([node for node in nodes if node.get("type") != "end"])
1140
- current_node_count = len(_summarize_workflow_nodes(self.workflows.workflow_list_nodes(profile=profile, app_key=app_key).get("result")))
2159
+ desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
2160
+ current_workflow, _workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
2161
+ current_node_count = len(_summarize_workflow_nodes(current_workflow))
1141
2162
  if current_node_count == desired_node_count and desired_node_count > 0:
1142
2163
  # Lightweight idempotency check for repeat submissions of same simple graph.
1143
2164
  pass
@@ -1149,42 +2170,57 @@ class AiBuilderFacade:
1149
2170
  os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
1150
2171
  try:
1151
2172
  assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
2173
+ manifest = default_manifest()
2174
+ manifest["solution_name"] = base.get("formTitle") or app_key
2175
+ manifest["preferences"]["create_package"] = False
2176
+ manifest["preferences"]["create_portal"] = False
2177
+ manifest["preferences"]["create_navigation"] = False
2178
+ manifest["entities"] = [entity]
2179
+ assembly.set_manifest(manifest)
2180
+ artifacts = default_artifacts()
2181
+ artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
2182
+ assembly.set_artifacts(artifacts)
2183
+ flow_stage_spec = {
2184
+ "solution_name": manifest["solution_name"],
2185
+ "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
2186
+ }
2187
+ assembly.set_stage_spec("app_flow", flow_stage_spec)
2188
+ stage = self.solutions.solution_build_flow(
2189
+ profile=profile,
2190
+ mode="apply",
2191
+ build_id=build_id,
2192
+ flow_spec=flow_stage_spec,
2193
+ publish=False,
2194
+ run_label=None,
2195
+ repair_patch={},
2196
+ )
1152
2197
  finally:
1153
2198
  if previous_build_home is None:
1154
2199
  os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
1155
- manifest = default_manifest()
1156
- manifest["solution_name"] = base.get("formTitle") or app_key
1157
- manifest["preferences"]["create_package"] = False
1158
- manifest["preferences"]["create_portal"] = False
1159
- manifest["preferences"]["create_navigation"] = False
1160
- manifest["entities"] = [entity]
1161
- assembly.set_manifest(manifest)
1162
- artifacts = default_artifacts()
1163
- artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
1164
- assembly.set_artifacts(artifacts)
1165
- stage = self.solutions.solution_build_flow(
1166
- profile=profile,
1167
- mode="apply",
1168
- build_id=build_id,
1169
- flow_spec={
1170
- "solution_name": manifest["solution_name"],
1171
- "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
1172
- },
1173
- publish=False,
1174
- run_label=None,
1175
- repair_patch={},
1176
- )
1177
2200
  if stage.get("status") != "success":
1178
2201
  failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
1179
2202
  failed["normalized_args"] = normalized_args
1180
- failed["suggested_next_call"] = failed.get("suggested_next_call") or {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
2203
+ suggested_next_call = failed.get("suggested_next_call")
2204
+ if not isinstance(suggested_next_call, dict):
2205
+ suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
2206
+ elif suggested_next_call.get("tool_name") == "app_flow_plan":
2207
+ arguments = suggested_next_call.get("arguments")
2208
+ if not isinstance(arguments, dict):
2209
+ arguments = {}
2210
+ arguments.setdefault("profile", profile)
2211
+ arguments.setdefault("app_key", app_key)
2212
+ arguments.setdefault("mode", mode)
2213
+ arguments.setdefault("nodes", public_nodes)
2214
+ arguments.setdefault("transitions", transitions)
2215
+ suggested_next_call["arguments"] = arguments
2216
+ failed["suggested_next_call"] = suggested_next_call
1181
2217
  return failed
1182
- verified_nodes = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key).get("result")
1183
- return {
1184
- "status": "success",
2218
+ verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
2219
+ response = {
2220
+ "status": "success" if bool(verified_nodes) or not verified_nodes_unavailable else "partial_success",
1185
2221
  "error_code": None,
1186
- "recoverable": False,
1187
- "message": "applied workflow patch",
2222
+ "recoverable": bool(verified_nodes_unavailable),
2223
+ "message": "applied workflow patch" if not verified_nodes_unavailable else "applied workflow patch; flow readback pending",
1188
2224
  "normalized_args": normalized_args,
1189
2225
  "missing_fields": [],
1190
2226
  "allowed_values": {"modes": ["replace"]},
@@ -1192,11 +2228,15 @@ class AiBuilderFacade:
1192
2228
  "request_id": None,
1193
2229
  "suggested_next_call": None,
1194
2230
  "noop": False,
1195
- "verification": {"workflow_verified": bool(verified_nodes)},
2231
+ "verification": {"workflow_verified": bool(verified_nodes), "workflow_read_unavailable": verified_nodes_unavailable},
1196
2232
  "app_key": app_key,
1197
2233
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
1198
2234
  "verified": bool(verified_nodes),
1199
2235
  }
2236
+ if verified_nodes_unavailable:
2237
+ response["error_code"] = "FLOW_READBACK_PENDING"
2238
+ response["suggested_next_call"] = {"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}}
2239
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1200
2240
 
1201
2241
  def app_views_apply(
1202
2242
  self,
@@ -1205,21 +2245,23 @@ class AiBuilderFacade:
1205
2245
  app_key: str,
1206
2246
  upsert_views: list[ViewUpsertPatch],
1207
2247
  remove_views: list[str],
2248
+ publish: bool = True,
1208
2249
  ) -> JSONObject:
1209
2250
  normalized_args = {
1210
2251
  "app_key": app_key,
1211
2252
  "upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
1212
2253
  "remove_views": list(remove_views),
2254
+ "publish": publish,
1213
2255
  }
1214
2256
  if not upsert_views and not remove_views:
1215
- return {
2257
+ response = {
1216
2258
  "status": "success",
1217
2259
  "error_code": None,
1218
2260
  "recoverable": False,
1219
2261
  "message": "no view changes requested",
1220
2262
  "normalized_args": normalized_args,
1221
2263
  "missing_fields": [],
1222
- "allowed_values": {"view_types": ["table", "card", "board"]},
2264
+ "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
1223
2265
  "details": {},
1224
2266
  "request_id": None,
1225
2267
  "suggested_next_call": None,
@@ -1229,41 +2271,72 @@ class AiBuilderFacade:
1229
2271
  "views_diff": {"created": [], "updated": [], "removed": []},
1230
2272
  "verified": True,
1231
2273
  }
1232
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1233
- schema = self.apps.app_get_form_schema(
1234
- profile=profile,
1235
- app_key=app_key,
1236
- form_type=1,
1237
- being_draft=True,
1238
- being_apply=None,
1239
- audit_node_id=None,
1240
- include_raw=True,
1241
- ).get("result") or {}
1242
- existing_views = self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []
1243
- existing_by_name = {}
2274
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2275
+ try:
2276
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2277
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2278
+ existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
2279
+ except (QingflowApiError, RuntimeError) as error:
2280
+ api_error = _coerce_api_error(error)
2281
+ return _failed_from_api_error(
2282
+ "VIEWS_READ_FAILED",
2283
+ api_error,
2284
+ normalized_args=normalized_args,
2285
+ details={"app_key": app_key},
2286
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2287
+ )
2288
+ existing_views = existing_views or []
2289
+ existing_by_key: dict[str, dict[str, Any]] = {}
2290
+ existing_by_name: dict[str, list[dict[str, Any]]] = {}
1244
2291
  for view in existing_views if isinstance(existing_views, list) else []:
1245
2292
  if not isinstance(view, dict):
1246
2293
  continue
1247
- name = str(view.get("viewgraphName") or view.get("viewName") or view.get("title") or "").strip()
1248
- key = str(view.get("viewgraphKey") or view.get("viewKey") or "").strip()
2294
+ name = _extract_view_name(view)
2295
+ key = _extract_view_key(view)
1249
2296
  if name and key:
1250
- existing_by_name[name] = key
2297
+ existing_by_key[key] = view
2298
+ existing_by_name.setdefault(name, []).append(view)
1251
2299
  parsed_schema = _parse_schema(schema)
1252
2300
  field_names = {field["name"] for field in parsed_schema["fields"]}
2301
+ current_fields_by_name = {
2302
+ str(field.get("name") or ""): field
2303
+ for field in parsed_schema["fields"]
2304
+ if isinstance(field, dict) and str(field.get("name") or "")
2305
+ }
1253
2306
  removed: list[str] = []
2307
+ view_results: list[dict[str, Any]] = []
1254
2308
  for name in remove_views:
1255
- key = existing_by_name.get(name)
1256
- if key:
2309
+ matches = existing_by_name.get(name, [])
2310
+ if len(matches) > 1:
2311
+ return _failed(
2312
+ "AMBIGUOUS_VIEW",
2313
+ "multiple views matched remove request; use app_read_views_summary and resolve duplicates before removing by name",
2314
+ normalized_args=normalized_args,
2315
+ details={
2316
+ "app_key": app_key,
2317
+ "view_name": name,
2318
+ "matches": [
2319
+ {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
2320
+ for view in matches
2321
+ ],
2322
+ },
2323
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2324
+ )
2325
+ if len(matches) == 1:
2326
+ key = _extract_view_key(matches[0])
1257
2327
  self.views.view_delete(profile=profile, viewgraph_key=key)
1258
2328
  removed.append(name)
1259
2329
  existing_by_name.pop(name, None)
2330
+ existing_by_key.pop(key, None)
2331
+ view_results.append({"name": name, "view_key": key, "type": None, "status": "removed"})
1260
2332
  created: list[str] = []
1261
2333
  updated: list[str] = []
2334
+ failed_views: list[dict[str, Any]] = []
1262
2335
  existing_view_list = [
1263
2336
  view
1264
2337
  for view in (existing_views if isinstance(existing_views, list) else [])
1265
2338
  if isinstance(view, dict)
1266
- and str(view.get("viewgraphName") or view.get("viewName") or view.get("title") or "").strip() not in remove_views
2339
+ and _extract_view_name(view) not in remove_views
1267
2340
  ]
1268
2341
  for ordinal, patch in enumerate(upsert_views, start=1):
1269
2342
  missing_columns = [name for name in patch.columns if name not in field_names]
@@ -1293,7 +2366,69 @@ class AiBuilderFacade:
1293
2366
  missing_fields=[patch.group_by],
1294
2367
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
1295
2368
  )
1296
- existing_key = existing_by_name.get(patch.name)
2369
+ for gantt_field_name in (patch.start_field, patch.end_field, patch.title_field):
2370
+ if gantt_field_name and gantt_field_name not in field_names:
2371
+ return _failed(
2372
+ "UNKNOWN_VIEW_FIELD",
2373
+ f"gantt configuration references unknown field '{gantt_field_name}'",
2374
+ normalized_args=normalized_args,
2375
+ details={
2376
+ "app_key": app_key,
2377
+ "view_name": patch.name,
2378
+ "missing_fields": [gantt_field_name],
2379
+ },
2380
+ missing_fields=[gantt_field_name],
2381
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
2382
+ )
2383
+ translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
2384
+ if filter_issues:
2385
+ first_issue = filter_issues[0]
2386
+ return _failed(
2387
+ str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
2388
+ "view filters reference invalid fields or values",
2389
+ normalized_args=normalized_args,
2390
+ details={
2391
+ "app_key": app_key,
2392
+ "view_name": patch.name,
2393
+ **first_issue,
2394
+ },
2395
+ missing_fields=list(first_issue.get("missing_fields") or []),
2396
+ allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
2397
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
2398
+ )
2399
+ matched_existing_view: dict[str, Any] | None = None
2400
+ existing_key: str | None = None
2401
+ if patch.view_key:
2402
+ matched_existing_view = existing_by_key.get(patch.view_key)
2403
+ if not matched_existing_view:
2404
+ return _failed(
2405
+ "UNKNOWN_VIEW",
2406
+ f"view_key '{patch.view_key}' does not exist on this app",
2407
+ normalized_args=normalized_args,
2408
+ details={"app_key": app_key, "view_key": patch.view_key, "view_name": patch.name},
2409
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2410
+ )
2411
+ existing_key = patch.view_key
2412
+ else:
2413
+ name_matches = existing_by_name.get(patch.name, [])
2414
+ if len(name_matches) > 1:
2415
+ return _failed(
2416
+ "AMBIGUOUS_VIEW",
2417
+ "multiple views share this name; supply view_key to update the exact target",
2418
+ normalized_args=normalized_args,
2419
+ details={
2420
+ "app_key": app_key,
2421
+ "view_name": patch.name,
2422
+ "matches": [
2423
+ {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
2424
+ for view in name_matches
2425
+ ],
2426
+ },
2427
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2428
+ )
2429
+ if len(name_matches) == 1:
2430
+ matched_existing_view = name_matches[0]
2431
+ existing_key = _extract_view_key(matched_existing_view)
1297
2432
  created_key: str | None = None
1298
2433
  try:
1299
2434
  if existing_key:
@@ -1303,9 +2438,19 @@ class AiBuilderFacade:
1303
2438
  source_viewgraph_key=existing_key,
1304
2439
  schema=schema,
1305
2440
  patch=patch,
2441
+ view_filters=translated_filters,
1306
2442
  )
1307
2443
  self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
1308
2444
  updated.append(patch.name)
2445
+ view_results.append(
2446
+ {
2447
+ "name": patch.name,
2448
+ "view_key": existing_key,
2449
+ "type": patch.type.value,
2450
+ "status": "updated",
2451
+ "expected_filters": deepcopy(translated_filters),
2452
+ }
2453
+ )
1309
2454
  else:
1310
2455
  template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
1311
2456
  if patch.type.value == "table" and template_key:
@@ -1317,6 +2462,7 @@ class AiBuilderFacade:
1317
2462
  source_viewgraph_key=created_key,
1318
2463
  schema=schema,
1319
2464
  patch=patch,
2465
+ view_filters=translated_filters,
1320
2466
  )
1321
2467
  self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
1322
2468
  else:
@@ -1326,52 +2472,304 @@ class AiBuilderFacade:
1326
2472
  schema=schema,
1327
2473
  patch=patch,
1328
2474
  ordinal=ordinal,
2475
+ view_filters=translated_filters,
1329
2476
  )
1330
2477
  self.views.view_create(profile=profile, payload=payload)
1331
2478
  created.append(patch.name)
2479
+ view_results.append(
2480
+ {
2481
+ "name": patch.name,
2482
+ "view_key": created_key,
2483
+ "type": patch.type.value,
2484
+ "status": "created",
2485
+ "expected_filters": deepcopy(translated_filters),
2486
+ }
2487
+ )
1332
2488
  except (QingflowApiError, RuntimeError) as error:
1333
2489
  api_error = _coerce_api_error(error)
2490
+ should_retry_minimal = api_error.backend_code == 48104 or (
2491
+ patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500
2492
+ )
2493
+ if should_retry_minimal:
2494
+ try:
2495
+ if existing_key or created_key:
2496
+ target_key = created_key or existing_key or ""
2497
+ fallback_payload = _build_minimal_view_payload(
2498
+ app_key=app_key,
2499
+ schema=schema,
2500
+ patch=patch,
2501
+ ordinal=ordinal,
2502
+ view_filters=translated_filters,
2503
+ )
2504
+ self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
2505
+ if existing_key:
2506
+ updated.append(patch.name)
2507
+ view_results.append(
2508
+ {
2509
+ "name": patch.name,
2510
+ "view_key": existing_key,
2511
+ "type": patch.type.value,
2512
+ "status": "updated",
2513
+ "fallback_applied": True,
2514
+ "expected_filters": deepcopy(translated_filters),
2515
+ }
2516
+ )
2517
+ else:
2518
+ created.append(patch.name)
2519
+ view_results.append(
2520
+ {
2521
+ "name": patch.name,
2522
+ "view_key": created_key,
2523
+ "type": patch.type.value,
2524
+ "status": "created",
2525
+ "fallback_applied": True,
2526
+ "expected_filters": deepcopy(translated_filters),
2527
+ }
2528
+ )
2529
+ continue
2530
+ fallback_payload = _build_minimal_view_payload(
2531
+ app_key=app_key,
2532
+ schema=schema,
2533
+ patch=patch,
2534
+ ordinal=ordinal,
2535
+ view_filters=translated_filters,
2536
+ )
2537
+ self.views.view_create(profile=profile, payload=fallback_payload)
2538
+ created.append(patch.name)
2539
+ view_results.append(
2540
+ {
2541
+ "name": patch.name,
2542
+ "view_key": created_key,
2543
+ "type": patch.type.value,
2544
+ "status": "created",
2545
+ "fallback_applied": True,
2546
+ "expected_filters": deepcopy(translated_filters),
2547
+ }
2548
+ )
2549
+ continue
2550
+ except (QingflowApiError, RuntimeError) as fallback_error:
2551
+ api_error = _coerce_api_error(fallback_error)
1334
2552
  if created_key:
1335
2553
  try:
1336
2554
  self.views.view_delete(profile=profile, viewgraph_key=created_key)
1337
2555
  except Exception:
1338
2556
  pass
1339
- return _failed_from_api_error(
1340
- "VIEW_APPLY_FAILED",
1341
- api_error,
1342
- normalized_args=normalized_args,
1343
- details={
2557
+ failure_entry = {
2558
+ "name": patch.name,
2559
+ "view_key": patch.view_key or existing_key or created_key,
2560
+ "type": patch.type.value,
2561
+ "status": "failed",
2562
+ "error_code": "VIEW_APPLY_FAILED",
2563
+ "message": _public_error_message("VIEW_APPLY_FAILED", api_error),
2564
+ "request_id": api_error.request_id,
2565
+ "backend_code": api_error.backend_code,
2566
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
2567
+ "operation": "update" if existing_key or created_key else "create",
2568
+ "details": {
1344
2569
  "app_key": app_key,
1345
2570
  "view_name": patch.name,
1346
2571
  "view_type": patch.type.value,
1347
2572
  "columns": patch.columns,
1348
2573
  "group_by": patch.group_by,
2574
+ "filters": [item.model_dump(mode="json") for item in patch.filters],
2575
+ "start_field": patch.start_field,
2576
+ "end_field": patch.end_field,
2577
+ "title_field": patch.title_field,
2578
+ "operation": "update" if existing_key or created_key else "create",
2579
+ "transport_error": {
2580
+ "http_status": api_error.http_status,
2581
+ "backend_code": api_error.backend_code,
2582
+ "category": api_error.category,
2583
+ },
1349
2584
  },
1350
- suggested_next_call={
1351
- "tool_name": "app_views_plan",
1352
- "arguments": {"profile": profile, "app_key": app_key},
1353
- },
2585
+ }
2586
+ failed_views.append(failure_entry)
2587
+ view_results.append(failure_entry)
2588
+ continue
2589
+ try:
2590
+ verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
2591
+ except (QingflowApiError, RuntimeError) as error:
2592
+ api_error = _coerce_api_error(error)
2593
+ return _failed_from_api_error(
2594
+ "VIEWS_READ_FAILED",
2595
+ api_error,
2596
+ normalized_args=normalized_args,
2597
+ details={"app_key": app_key},
2598
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2599
+ )
2600
+ verified_names = {
2601
+ _extract_view_name(item)
2602
+ for item in (verified_view_result or [])
2603
+ if isinstance(item, dict) and _extract_view_name(item)
2604
+ }
2605
+ verified_by_key = {
2606
+ _extract_view_key(item): item
2607
+ for item in (verified_view_result or [])
2608
+ if isinstance(item, dict) and _extract_view_key(item)
2609
+ }
2610
+ verified_view_keys_by_name: dict[str, list[str]] = {}
2611
+ for item in (verified_view_result or []):
2612
+ if not isinstance(item, dict):
2613
+ continue
2614
+ name = _extract_view_name(item)
2615
+ key = _extract_view_key(item)
2616
+ if name and key:
2617
+ verified_view_keys_by_name.setdefault(name, []).append(key)
2618
+ verification_by_view: list[dict[str, Any]] = []
2619
+ filter_readback_pending = False
2620
+ filter_mismatches: list[dict[str, Any]] = []
2621
+ for item in view_results:
2622
+ status = str(item.get("status") or "")
2623
+ name = str(item.get("name") or "")
2624
+ item_view_key = str(item.get("view_key") or "").strip()
2625
+ present_in_readback: bool | None
2626
+ if status in {"created", "updated"}:
2627
+ if verified_views_unavailable:
2628
+ present_in_readback = None
2629
+ elif item_view_key:
2630
+ present_in_readback = item_view_key in verified_by_key
2631
+ else:
2632
+ present_in_readback = name in verified_names
2633
+ verification_entry: dict[str, Any] = {
2634
+ "name": name,
2635
+ "view_key": item_view_key or None,
2636
+ "type": item.get("type"),
2637
+ "status": status,
2638
+ "present_in_readback": present_in_readback,
2639
+ }
2640
+ expected_filters = item.get("expected_filters") or []
2641
+ if expected_filters:
2642
+ if verified_views_unavailable or not present_in_readback:
2643
+ verification_entry["filters_verified"] = None
2644
+ verification_entry["filter_readback_pending"] = True
2645
+ filter_readback_pending = True
2646
+ else:
2647
+ verification_key = item_view_key
2648
+ if not verification_key:
2649
+ matched_keys = verified_view_keys_by_name.get(name) or []
2650
+ if len(matched_keys) == 1:
2651
+ verification_key = matched_keys[0]
2652
+ else:
2653
+ verification_entry["filters_verified"] = None
2654
+ verification_entry["filter_readback_pending"] = True
2655
+ verification_entry["readback_ambiguous"] = True
2656
+ verification_entry["matching_view_keys"] = matched_keys
2657
+ filter_readback_pending = True
2658
+ verification_by_view.append(verification_entry)
2659
+ continue
2660
+ try:
2661
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
2662
+ actual_filters = _normalize_view_filter_groups_for_compare((config_response.get("result") or {}).get("viewgraphLimit"))
2663
+ expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
2664
+ filters_verified = actual_filters == expected_filter_summary
2665
+ verification_entry["filters_verified"] = filters_verified
2666
+ verification_entry["view_key"] = verification_key
2667
+ verification_entry["expected_filters"] = expected_filter_summary
2668
+ verification_entry["actual_filters"] = actual_filters
2669
+ if not filters_verified:
2670
+ filter_mismatches.append(
2671
+ {
2672
+ "name": name,
2673
+ "type": item.get("type"),
2674
+ "expected_filters": expected_filter_summary,
2675
+ "actual_filters": actual_filters,
2676
+ }
2677
+ )
2678
+ except (QingflowApiError, RuntimeError) as error:
2679
+ api_error = _coerce_api_error(error)
2680
+ verification_entry["filters_verified"] = None
2681
+ verification_entry["filter_readback_pending"] = True
2682
+ verification_entry["request_id"] = api_error.request_id
2683
+ verification_entry["transport_error"] = {
2684
+ "http_status": api_error.http_status,
2685
+ "backend_code": api_error.backend_code,
2686
+ "category": api_error.category,
2687
+ }
2688
+ filter_readback_pending = True
2689
+ verification_by_view.append(verification_entry)
2690
+ elif status == "removed":
2691
+ verification_by_view.append(
2692
+ {
2693
+ "name": name,
2694
+ "type": item.get("type"),
2695
+ "status": "removed",
2696
+ "present_in_readback": None if verified_views_unavailable else name not in verified_names,
2697
+ }
2698
+ )
2699
+ else:
2700
+ verification_by_view.append(
2701
+ {
2702
+ "name": item.get("name"),
2703
+ "type": item.get("type"),
2704
+ "status": "failed",
2705
+ "present_in_readback": None,
2706
+ "error_code": item.get("error_code"),
2707
+ }
1354
2708
  )
1355
- verified_names = {item.get("viewgraphName") or item.get("viewName") or item.get("title") for item in (self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []) if isinstance(item, dict)}
1356
- verified = all(name in verified_names for name in created + updated) and all(name not in verified_names for name in removed)
2709
+ verified = (
2710
+ (not verified_views_unavailable)
2711
+ and all(name in verified_names for name in created + updated)
2712
+ and all(name not in verified_names for name in removed)
2713
+ and not filter_readback_pending
2714
+ and not filter_mismatches
2715
+ )
1357
2716
  noop = not created and not updated and not removed
1358
- return {
2717
+ if failed_views:
2718
+ successful_changes = bool(created or updated or removed)
2719
+ first_failure = failed_views[0]
2720
+ response = {
2721
+ "status": "partial_success" if successful_changes else "failed",
2722
+ "error_code": "VIEW_APPLY_PARTIAL" if successful_changes else "VIEW_APPLY_FAILED",
2723
+ "recoverable": True,
2724
+ "message": "applied some view patches; at least one view failed" if successful_changes else "one or more view patches failed",
2725
+ "normalized_args": normalized_args,
2726
+ "missing_fields": [],
2727
+ "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
2728
+ "details": {"per_view_results": view_results, "filter_mismatches": filter_mismatches},
2729
+ "request_id": first_failure.get("request_id"),
2730
+ "suggested_next_call": {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2731
+ "backend_code": first_failure.get("backend_code"),
2732
+ "http_status": first_failure.get("http_status"),
2733
+ "noop": noop,
2734
+ "verification": {
2735
+ "views_verified": verified,
2736
+ "views_read_unavailable": verified_views_unavailable,
2737
+ "by_view": verification_by_view,
2738
+ },
2739
+ "app_key": app_key,
2740
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
2741
+ "verified": verified,
2742
+ }
2743
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2744
+ response = {
1359
2745
  "status": "success" if verified else "partial_success",
1360
- "error_code": None,
1361
- "recoverable": False,
1362
- "message": "applied view patch",
2746
+ "error_code": None if verified else ("VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
2747
+ "recoverable": not verified,
2748
+ "message": (
2749
+ "applied view patch"
2750
+ if verified
2751
+ else "applied view patch; filters did not fully verify"
2752
+ if filter_mismatches
2753
+ else "applied view patch; views readback pending"
2754
+ ),
1363
2755
  "normalized_args": normalized_args,
1364
2756
  "missing_fields": [],
1365
- "allowed_values": {"view_types": ["table", "card", "board"]},
1366
- "details": {},
2757
+ "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
2758
+ "details": {"filter_mismatches": filter_mismatches} if filter_mismatches else {},
1367
2759
  "request_id": None,
1368
- "suggested_next_call": None,
2760
+ "suggested_next_call": None if verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1369
2761
  "noop": noop,
1370
- "verification": {"views_verified": verified},
2762
+ "verification": {
2763
+ "views_verified": verified,
2764
+ "views_read_unavailable": verified_views_unavailable,
2765
+ "filter_readback_pending": filter_readback_pending,
2766
+ "by_view": verification_by_view,
2767
+ },
1371
2768
  "app_key": app_key,
1372
- "views_diff": {"created": created, "updated": updated, "removed": removed},
2769
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
1373
2770
  "verified": verified,
1374
2771
  }
2772
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1375
2773
 
1376
2774
  def app_publish_verify(
1377
2775
  self,
@@ -1381,12 +2779,33 @@ class AiBuilderFacade:
1381
2779
  expected_package_tag_id: int | None = None,
1382
2780
  ) -> JSONObject:
1383
2781
  normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
1384
- base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2782
+ try:
2783
+ base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2784
+ except (QingflowApiError, RuntimeError) as error:
2785
+ api_error = _coerce_api_error(error)
2786
+ return _failed_from_api_error(
2787
+ "APP_READ_FAILED",
2788
+ api_error,
2789
+ normalized_args=normalized_args,
2790
+ details={"app_key": app_key},
2791
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
2792
+ )
1385
2793
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
1386
2794
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
1387
2795
  package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
1388
- views_before = self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []
1389
- if already_published and package_already_attached is not False and isinstance(views_before, list):
2796
+ try:
2797
+ views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
2798
+ except (QingflowApiError, RuntimeError) as error:
2799
+ api_error = _coerce_api_error(error)
2800
+ return _failed_from_api_error(
2801
+ "VIEWS_READ_FAILED",
2802
+ api_error,
2803
+ normalized_args=normalized_args,
2804
+ details={"app_key": app_key},
2805
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2806
+ )
2807
+ views_before = views_before or []
2808
+ if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
1390
2809
  return {
1391
2810
  "status": "success",
1392
2811
  "error_code": None,
@@ -1410,28 +2829,49 @@ class AiBuilderFacade:
1410
2829
  version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
1411
2830
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
1412
2831
  try:
1413
- self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
1414
- self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
2832
+ self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
2833
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
2834
+ except (QingflowApiError, RuntimeError) as error:
2835
+ api_error = _coerce_api_error(error)
2836
+ return _failed_from_api_error(
2837
+ "PUBLISH_FAILED",
2838
+ api_error,
2839
+ normalized_args=normalized_args,
2840
+ details={"app_key": app_key, "edit_version_no": edit_version_no},
2841
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
2842
+ )
2843
+ try:
2844
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2845
+ except (QingflowApiError, RuntimeError) as error:
2846
+ api_error = _coerce_api_error(error)
2847
+ return _failed_from_api_error(
2848
+ "APP_READ_FAILED",
2849
+ api_error,
2850
+ normalized_args=normalized_args,
2851
+ details={"app_key": app_key},
2852
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
2853
+ )
2854
+ tag_ids_after = _coerce_int_list(base.get("tagIds"))
2855
+ package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
2856
+ try:
2857
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
1415
2858
  except (QingflowApiError, RuntimeError) as error:
1416
2859
  api_error = _coerce_api_error(error)
1417
2860
  return _failed_from_api_error(
1418
- "PUBLISH_FAILED",
2861
+ "VIEWS_READ_FAILED",
1419
2862
  api_error,
1420
2863
  normalized_args=normalized_args,
1421
- details={"app_key": app_key, "edit_version_no": edit_version_no},
1422
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
2864
+ details={"app_key": app_key},
2865
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1423
2866
  )
1424
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1425
- tag_ids_after = _coerce_int_list(base.get("tagIds"))
1426
- package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
1427
- views = self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []
1428
- views_ok = isinstance(views, list)
2867
+ views = views or []
2868
+ views_ok = isinstance(views, list) and not views_unavailable
1429
2869
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
1430
2870
  return {
1431
2871
  "status": "success" if verified else "partial_success",
1432
- "error_code": None,
1433
- "recoverable": False,
1434
- "message": "published and verified app",
2872
+ "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
2873
+ "recoverable": bool(views_unavailable),
2874
+ "message": "published and verified app" if not views_unavailable else "published app; views readback pending",
1435
2875
  "normalized_args": normalized_args,
1436
2876
  "missing_fields": [],
1437
2877
  "allowed_values": {},
@@ -1444,7 +2884,7 @@ class AiBuilderFacade:
1444
2884
  "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
1445
2885
  },
1446
2886
  "noop": False,
1447
- "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok},
2887
+ "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
1448
2888
  "app_key": app_key,
1449
2889
  "published": bool(base.get("appPublishStatus") in {1, 2}),
1450
2890
  "package_attached": package_attached,
@@ -1453,29 +2893,163 @@ class AiBuilderFacade:
1453
2893
  "verified": verified,
1454
2894
  }
1455
2895
 
1456
- def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
2896
+ def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
2897
+ normalized_args = {"app_key": app_key}
2898
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
2899
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
2900
+ try:
2901
+ self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
2902
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
2903
+ except (QingflowApiError, RuntimeError) as error:
2904
+ api_error = _coerce_api_error(error)
2905
+ return _failed_from_api_error(
2906
+ "PUBLISH_FAILED",
2907
+ api_error,
2908
+ normalized_args=normalized_args,
2909
+ details={"app_key": app_key, "edit_version_no": edit_version_no},
2910
+ suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, "app_key": app_key}},
2911
+ )
2912
+ return {
2913
+ "status": "success",
2914
+ "error_code": None,
2915
+ "recoverable": False,
2916
+ "message": "published current app draft",
2917
+ "normalized_args": normalized_args,
2918
+ "missing_fields": [],
2919
+ "allowed_values": {},
2920
+ "details": {"app_key": app_key, "edit_version_no": edit_version_no},
2921
+ "request_id": None,
2922
+ "suggested_next_call": None,
2923
+ "noop": False,
2924
+ "verification": {"published": True},
2925
+ "app_key": app_key,
2926
+ "published": True,
2927
+ }
2928
+
2929
+ def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
2930
+ try:
2931
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
2932
+ except (QingflowApiError, RuntimeError):
2933
+ version_result = {}
2934
+ return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
2935
+
2936
+ def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
2937
+ response["publish_requested"] = publish
2938
+ if not publish:
2939
+ response["published"] = False
2940
+ return response
2941
+ publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key)
2942
+ response["publish_result"] = publish_result
2943
+ response["published"] = bool(publish_result.get("published"))
2944
+ verification = response.get("verification")
2945
+ if not isinstance(verification, dict):
2946
+ verification = {}
2947
+ response["verification"] = verification
2948
+ verification["published"] = bool(publish_result.get("published"))
2949
+ if publish_result.get("status") == "failed":
2950
+ response["status"] = "partial_success"
2951
+ response["error_code"] = response.get("error_code") or publish_result.get("error_code")
2952
+ response["recoverable"] = True
2953
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
2954
+ if not response.get("suggested_next_call"):
2955
+ response["suggested_next_call"] = publish_result.get("suggested_next_call")
2956
+ return response
2957
+
2958
+ def _load_base_schema_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
1457
2959
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
1458
- schema = self.apps.app_get_form_schema(
1459
- profile=profile,
1460
- app_key=app_key,
1461
- form_type=1,
1462
- being_draft=True,
1463
- being_apply=None,
1464
- audit_node_id=None,
1465
- include_raw=True,
1466
- )
1467
- views = self.views.view_list_flat(profile=profile, app_key=app_key)
1468
- workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
2960
+ schema_result, schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1469
2961
  base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
1470
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
1471
2962
  return {
1472
2963
  "base": base_result,
1473
2964
  "schema": schema_result,
1474
2965
  "parsed": _parse_schema(schema_result),
1475
- "views": views.get("result"),
1476
- "workflow": workflow.get("result"),
2966
+ "schema_source": schema_source,
1477
2967
  }
1478
2968
 
2969
+ def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
2970
+ try:
2971
+ views = self.views.view_list_flat(profile=profile, app_key=app_key)
2972
+ except (QingflowApiError, RuntimeError) as error:
2973
+ api_error = _coerce_api_error(error)
2974
+ if api_error.http_status == 404:
2975
+ try:
2976
+ legacy_views = self.views.view_list(profile=profile, app_key=app_key)
2977
+ except (QingflowApiError, RuntimeError) as legacy_error:
2978
+ legacy_api_error = _coerce_api_error(legacy_error)
2979
+ if tolerate_404 and legacy_api_error.http_status == 404:
2980
+ return [], True
2981
+ raise
2982
+ legacy_result = legacy_views.get("result")
2983
+ if _is_view_collection_shape(legacy_result):
2984
+ return _normalize_view_collection(legacy_result), False
2985
+ if tolerate_404:
2986
+ return [], True
2987
+ raise error
2988
+ raise
2989
+ normalized_views = _normalize_view_collection(views.get("result"))
2990
+ if normalized_views:
2991
+ return normalized_views, False
2992
+ try:
2993
+ legacy_views = self.views.view_list(profile=profile, app_key=app_key)
2994
+ except (QingflowApiError, RuntimeError) as legacy_error:
2995
+ legacy_api_error = _coerce_api_error(legacy_error)
2996
+ if tolerate_404 and legacy_api_error.http_status == 404:
2997
+ return normalized_views, False
2998
+ raise
2999
+ legacy_result = legacy_views.get("result")
3000
+ legacy_normalized = _normalize_view_collection(legacy_result)
3001
+ return legacy_normalized or normalized_views, False
3002
+
3003
+ def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
3004
+ try:
3005
+ workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
3006
+ except (QingflowApiError, RuntimeError) as error:
3007
+ api_error = _coerce_api_error(error)
3008
+ if tolerate_404 and api_error.http_status == 404:
3009
+ return [], True
3010
+ raise
3011
+ return workflow.get("result"), False
3012
+
3013
+ def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
3014
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
3015
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3016
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
3017
+ state["views"] = views
3018
+ state["workflow"] = workflow
3019
+ state["views_unavailable"] = views_unavailable
3020
+ state["workflow_unavailable"] = workflow_unavailable
3021
+ return state
3022
+
3023
+ def _read_schema_with_fallback(self, *, profile: str, app_key: str) -> tuple[dict[str, Any], str]:
3024
+ attempts = (
3025
+ ("draft", True),
3026
+ ("current", None),
3027
+ ("published", False),
3028
+ )
3029
+ last_error: Exception | None = None
3030
+ for label, being_draft in attempts:
3031
+ try:
3032
+ schema = self.apps.app_get_form_schema(
3033
+ profile=profile,
3034
+ app_key=app_key,
3035
+ form_type=1,
3036
+ being_draft=being_draft,
3037
+ being_apply=None,
3038
+ audit_node_id=None,
3039
+ include_raw=True,
3040
+ )
3041
+ result = schema.get("result")
3042
+ return (result if isinstance(result, dict) else {}), label
3043
+ except (QingflowApiError, RuntimeError) as error:
3044
+ api_error = _coerce_api_error(error)
3045
+ last_error = error
3046
+ if api_error.http_status == 404:
3047
+ continue
3048
+ raise
3049
+ if last_error is not None:
3050
+ raise last_error
3051
+ return {}, "unknown"
3052
+
1479
3053
  def _preview_target_app(
1480
3054
  self,
1481
3055
  *,
@@ -1563,7 +3137,28 @@ class AiBuilderFacade:
1563
3137
  new_app_key = str(result.get("appKey") or (result.get("appKeys")[0] if isinstance(result.get("appKeys"), list) and result.get("appKeys") else ""))
1564
3138
  if not new_app_key:
1565
3139
  return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
1566
- base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
3140
+ try:
3141
+ base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
3142
+ except (QingflowApiError, RuntimeError) as error:
3143
+ api_error = _coerce_api_error(error)
3144
+ if api_error.http_status != 404:
3145
+ return _failed_from_api_error(
3146
+ "APP_CREATE_READBACK_FAILED",
3147
+ api_error,
3148
+ details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
3149
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": new_app_key}},
3150
+ )
3151
+ return {
3152
+ "status": "success",
3153
+ "error_code": None,
3154
+ "recoverable": False,
3155
+ "message": "created app; base readback pending",
3156
+ "suggested_next_call": None,
3157
+ "app_key": new_app_key,
3158
+ "app_name": app_name or "未命名应用",
3159
+ "tag_ids": [],
3160
+ "created": True,
3161
+ }
1567
3162
  return {
1568
3163
  "status": "success",
1569
3164
  "error_code": None,
@@ -1594,6 +3189,33 @@ class AiBuilderFacade:
1594
3189
  payload.setdefault("ws_id", session_profile.selected_ws_id)
1595
3190
  return payload
1596
3191
 
3192
+ def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
3193
+ session_profile = self.apps.sessions.get_profile(profile)
3194
+ backend_session = self.apps.sessions.get_backend_session(profile)
3195
+ current_email = str((session_profile.email if session_profile else None) or "").strip()
3196
+ current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
3197
+ if current_email or current_name or session_profile is None or backend_session is None:
3198
+ return {"email": current_email or None, "nick_name": current_name or None}
3199
+ try:
3200
+ user_info = self.apps.backend.request(
3201
+ "GET",
3202
+ BackendRequestContext(
3203
+ base_url=backend_session.base_url,
3204
+ token=backend_session.token,
3205
+ ws_id=session_profile.selected_ws_id,
3206
+ qf_version=backend_session.qf_version,
3207
+ qf_version_source=backend_session.qf_version_source,
3208
+ ),
3209
+ "/user",
3210
+ )
3211
+ except (QingflowApiError, RuntimeError):
3212
+ return {"email": current_email or None, "nick_name": current_name or None}
3213
+ if not isinstance(user_info, dict):
3214
+ return {"email": current_email or None, "nick_name": current_name or None}
3215
+ resolved_email = str(user_info.get("email") or "").strip() or None
3216
+ resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
3217
+ return {"email": resolved_email, "nick_name": resolved_name}
3218
+
1597
3219
  def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
1598
3220
  detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
1599
3221
  result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
@@ -1658,18 +3280,49 @@ def _failed_from_api_error(
1658
3280
  suggested_next_call: JSONObject | None = None,
1659
3281
  recoverable: bool = True,
1660
3282
  ) -> JSONObject:
3283
+ effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
3284
+ public_message = _public_error_message(effective_error_code, error)
3285
+ public_http_status = None if error.http_status == 404 else error.http_status
3286
+ merged_details = dict(details or {})
3287
+ if error.backend_code == 40074:
3288
+ owner = _extract_edit_lock_owner(error.message)
3289
+ merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
3290
+ merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
3291
+ app_key = None
3292
+ if isinstance(normalized_args, dict):
3293
+ app_key = normalized_args.get("app_key")
3294
+ if not app_key and isinstance(details, dict):
3295
+ app_key = details.get("app_key")
3296
+ if isinstance(app_key, str) and app_key.strip():
3297
+ suggested_next_call = {
3298
+ "tool_name": "app_release_edit_lock_if_mine",
3299
+ "arguments": {
3300
+ "app_key": app_key,
3301
+ "lock_owner_name": owner.get("lock_owner_name") or "",
3302
+ "lock_owner_email": owner.get("lock_owner_email") or "",
3303
+ },
3304
+ }
3305
+ if error.http_status is not None or error.backend_code is not None:
3306
+ merged_details.setdefault(
3307
+ "transport_error",
3308
+ {
3309
+ "http_status": error.http_status,
3310
+ "backend_code": error.backend_code,
3311
+ "category": error.category,
3312
+ },
3313
+ )
1661
3314
  return _failed(
1662
- error_code,
1663
- error.message,
3315
+ effective_error_code,
3316
+ public_message,
1664
3317
  recoverable=recoverable,
1665
3318
  normalized_args=normalized_args,
1666
3319
  missing_fields=missing_fields,
1667
3320
  allowed_values=allowed_values,
1668
- details=details,
3321
+ details=merged_details,
1669
3322
  suggested_next_call=suggested_next_call,
1670
3323
  request_id=error.request_id,
1671
3324
  backend_code=error.backend_code,
1672
- http_status=error.http_status,
3325
+ http_status=public_http_status,
1673
3326
  )
1674
3327
 
1675
3328
 
@@ -1708,6 +3361,52 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
1708
3361
  return QingflowApiError(category="runtime", message=str(error))
1709
3362
 
1710
3363
 
3364
+ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
3365
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
3366
+ owner = _extract_edit_lock_owner(error.message)
3367
+ owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
3368
+ if owner_label:
3369
+ return f"app is currently locked by active editor {owner_label}"
3370
+ return "app is currently locked by another active editor session"
3371
+ if error.http_status != 404:
3372
+ return error.message
3373
+ mapping = {
3374
+ "APP_READ_FAILED": "app base or schema is unavailable in the current route",
3375
+ "FIELDS_READ_FAILED": "app fields are unavailable in the current route",
3376
+ "LAYOUT_READ_FAILED": "layout resource is unavailable for this app in the current route",
3377
+ "VIEWS_READ_FAILED": "views resource is unavailable for this app in the current route",
3378
+ "FLOW_READ_FAILED": "workflow resource is unavailable for this app in the current route",
3379
+ "SCHEMA_READBACK_FAILED": "schema was written but schema readback is unavailable in the current route",
3380
+ "CREATE_APP_ROUTE_NOT_FOUND": "create app route is unavailable in the current workspace route",
3381
+ "APP_CREATE_READBACK_FAILED": "app was created but base readback is unavailable in the current route",
3382
+ "PACKAGE_ATTACH_FAILED": "package attachment could not be verified in the current route",
3383
+ "PUBLISH_FAILED": "publish route is unavailable in the current route",
3384
+ "VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
3385
+ "LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
3386
+ "SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
3387
+ "EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
3388
+ }
3389
+ return mapping.get(error_code, "requested builder resource is unavailable in the current route")
3390
+
3391
+
3392
+ def _extract_edit_lock_owner(message: str) -> JSONObject:
3393
+ text = str(message or "").strip()
3394
+ if not text:
3395
+ return {"lock_owner_name": None, "lock_owner_email": None}
3396
+ patterns = [
3397
+ r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
3398
+ r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
3399
+ ]
3400
+ for pattern in patterns:
3401
+ match = re.search(pattern, text)
3402
+ if match:
3403
+ return {
3404
+ "lock_owner_name": match.groupdict().get("name", "").strip() or None,
3405
+ "lock_owner_email": match.groupdict().get("email", "").strip() or None,
3406
+ }
3407
+ return {"lock_owner_name": None, "lock_owner_email": None}
3408
+
3409
+
1711
3410
  def _coerce_positive_int(value: Any) -> int | None:
1712
3411
  if isinstance(value, bool) or value is None:
1713
3412
  return None
@@ -1735,6 +3434,51 @@ def _coerce_int_list(values: Any) -> list[int]:
1735
3434
  return result
1736
3435
 
1737
3436
 
3437
+ def _normalize_view_collection(values: Any) -> list[dict[str, Any]]:
3438
+ if isinstance(values, list):
3439
+ normalized: list[dict[str, Any]] = []
3440
+ for item in values:
3441
+ if not isinstance(item, dict):
3442
+ continue
3443
+ nested_view_list = item.get("viewList")
3444
+ if isinstance(nested_view_list, list):
3445
+ normalized.extend(view for view in nested_view_list if isinstance(view, dict))
3446
+ continue
3447
+ normalized.append(item)
3448
+ return normalized
3449
+ if isinstance(values, dict):
3450
+ for key in ("list", "viewList", "views", "result"):
3451
+ candidate = values.get(key)
3452
+ if isinstance(candidate, list):
3453
+ return _normalize_view_collection(candidate)
3454
+ return []
3455
+
3456
+
3457
+ def _is_view_collection_shape(values: Any) -> bool:
3458
+ if isinstance(values, list):
3459
+ return True
3460
+ if isinstance(values, dict):
3461
+ return any(isinstance(values.get(key), list) for key in ("list", "viewList", "views", "result"))
3462
+ return False
3463
+
3464
+
3465
+ def _extract_view_name(view: dict[str, Any]) -> str:
3466
+ return str(view.get("viewgraphName") or view.get("viewName") or view.get("title") or "").strip()
3467
+
3468
+
3469
+ def _extract_view_key(view: dict[str, Any]) -> str:
3470
+ return str(view.get("viewgraphKey") or view.get("viewKey") or "").strip()
3471
+
3472
+
3473
+ def _empty_schema_result(title: str) -> dict[str, Any]:
3474
+ return {
3475
+ "formTitle": title,
3476
+ "editVersionNo": 1,
3477
+ "formQues": [],
3478
+ "questionRelations": [],
3479
+ }
3480
+
3481
+
1738
3482
  def _slugify(text: str, *, default: str) -> str:
1739
3483
  normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(text or ""))
1740
3484
  collapsed = "_".join(part for part in normalized.split("_") if part)
@@ -1753,6 +3497,7 @@ def _infer_field_type(question: dict[str, Any]) -> str:
1753
3497
  def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None) -> dict[str, Any]:
1754
3498
  name = str(question.get("queTitle") or "").strip()
1755
3499
  que_id = _coerce_positive_int(question.get("queId"))
3500
+ que_type = _coerce_positive_int(question.get("queType"))
1756
3501
  field_type = _infer_field_type(question)
1757
3502
  field_id = field_id_hint or f"field_{que_id or _slugify(name, default='x')}"
1758
3503
  field: dict[str, Any] = {
@@ -1762,14 +3507,30 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
1762
3507
  "required": bool(question.get("required")),
1763
3508
  "description": question.get("queHint") or None,
1764
3509
  "options": [],
3510
+ "option_details": [],
1765
3511
  "target_app_key": None,
1766
3512
  "subfields": [],
1767
3513
  "que_id": que_id,
3514
+ "que_type": que_type,
1768
3515
  }
1769
3516
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
1770
3517
  options = question.get("options")
1771
3518
  if isinstance(options, list):
1772
- field["options"] = [str(item.get("optValue") or "") for item in options if isinstance(item, dict) and item.get("optValue")]
3519
+ option_values: list[str] = []
3520
+ option_details: list[dict[str, Any]] = []
3521
+ for item in options:
3522
+ if not isinstance(item, dict) or not item.get("optValue"):
3523
+ continue
3524
+ option_value = str(item.get("optValue") or "")
3525
+ option_values.append(option_value)
3526
+ option_details.append(
3527
+ {
3528
+ "id": item.get("optId"),
3529
+ "value": option_value,
3530
+ }
3531
+ )
3532
+ field["options"] = option_values
3533
+ field["option_details"] = option_details
1773
3534
  if field_type == FieldType.relation:
1774
3535
  reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
1775
3536
  field["target_app_key"] = reference.get("referAppKey")
@@ -1827,6 +3588,79 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
1827
3588
  return {"fields": fields, "layout": {"root_rows": root_rows, "sections": sections}}
1828
3589
 
1829
3590
 
3591
+ def _resolve_layout_sections_to_names(
3592
+ requested_sections: list[dict[str, Any]],
3593
+ fields: list[dict[str, Any]],
3594
+ ) -> tuple[list[dict[str, Any]], list[Any]]:
3595
+ by_name = {str(field.get("name") or ""): str(field.get("name") or "") for field in fields if field.get("name")}
3596
+ by_field_id = {
3597
+ str(field.get("field_id") or ""): str(field.get("name") or "")
3598
+ for field in fields
3599
+ if field.get("field_id") and field.get("name")
3600
+ }
3601
+ by_que_id = {
3602
+ int(field.get("que_id")): str(field.get("name") or "")
3603
+ for field in fields
3604
+ if _coerce_positive_int(field.get("que_id")) is not None and field.get("name")
3605
+ }
3606
+ normalized_sections: list[dict[str, Any]] = []
3607
+ missing_selectors: list[Any] = []
3608
+ for section in requested_sections:
3609
+ if not isinstance(section, dict):
3610
+ continue
3611
+ normalized_rows: list[list[str]] = []
3612
+ for row in section.get("rows", []) or []:
3613
+ if not isinstance(row, list):
3614
+ continue
3615
+ normalized_row: list[str] = []
3616
+ for selector in row:
3617
+ resolved_name = _resolve_layout_field_name(selector, by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3618
+ if resolved_name is None:
3619
+ missing_selectors.append(selector)
3620
+ continue
3621
+ normalized_row.append(resolved_name)
3622
+ if normalized_row:
3623
+ normalized_rows.append(normalized_row)
3624
+ normalized_section = deepcopy(section)
3625
+ normalized_section["rows"] = normalized_rows
3626
+ normalized_sections.append(normalized_section)
3627
+ return normalized_sections, missing_selectors
3628
+
3629
+
3630
+ def _resolve_layout_field_name(
3631
+ selector: Any,
3632
+ *,
3633
+ by_name: dict[str, str],
3634
+ by_field_id: dict[str, str],
3635
+ by_que_id: dict[int, str],
3636
+ ) -> str | None:
3637
+ if isinstance(selector, str):
3638
+ stripped = selector.strip()
3639
+ if not stripped:
3640
+ return None
3641
+ if stripped in by_name:
3642
+ return by_name[stripped]
3643
+ if stripped in by_field_id:
3644
+ return by_field_id[stripped]
3645
+ if stripped.isdigit():
3646
+ return by_que_id.get(int(stripped))
3647
+ return None
3648
+ if isinstance(selector, int) and not isinstance(selector, bool):
3649
+ return by_que_id.get(selector)
3650
+ if isinstance(selector, dict):
3651
+ if selector.get("name"):
3652
+ return _resolve_layout_field_name(selector.get("name"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3653
+ if selector.get("title"):
3654
+ return _resolve_layout_field_name(selector.get("title"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3655
+ if selector.get("label"):
3656
+ return _resolve_layout_field_name(selector.get("label"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3657
+ if selector.get("field_id"):
3658
+ return _resolve_layout_field_name(selector.get("field_id"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3659
+ if selector.get("que_id") is not None:
3660
+ return _resolve_layout_field_name(selector.get("que_id"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
3661
+ return None
3662
+
3663
+
1830
3664
  def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
1831
3665
  mapping: dict[str, int] = {}
1832
3666
  for index, field in enumerate(fields):
@@ -2112,11 +3946,147 @@ def _build_flow_preset(preset: FlowPreset) -> tuple[list[dict[str, Any]], list[d
2112
3946
  return nodes, transitions
2113
3947
 
2114
3948
 
3949
+ def _merge_flow_graph(
3950
+ *,
3951
+ base_nodes: list[dict[str, Any]],
3952
+ base_transitions: list[dict[str, Any]],
3953
+ override_nodes: list[dict[str, Any]],
3954
+ override_transitions: list[dict[str, Any]],
3955
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
3956
+ if not override_nodes and not override_transitions:
3957
+ return deepcopy(base_nodes), deepcopy(base_transitions)
3958
+ override_nodes, override_transitions = _align_flow_preset_override_ids(
3959
+ base_nodes=base_nodes,
3960
+ override_nodes=override_nodes,
3961
+ override_transitions=override_transitions,
3962
+ )
3963
+ merged_nodes: list[dict[str, Any]] = []
3964
+ override_map = {
3965
+ str(node.get("id") or ""): deepcopy(node)
3966
+ for node in override_nodes
3967
+ if isinstance(node, dict) and str(node.get("id") or "")
3968
+ }
3969
+ consumed: set[str] = set()
3970
+ for node in base_nodes:
3971
+ node_id = str(node.get("id") or "")
3972
+ if node_id and node_id in override_map:
3973
+ merged = deepcopy(node)
3974
+ merged.update(override_map[node_id])
3975
+ if isinstance(node.get("config"), dict) and isinstance(override_map[node_id].get("config"), dict):
3976
+ merged["config"] = {**deepcopy(node["config"]), **deepcopy(override_map[node_id]["config"])}
3977
+ if isinstance(node.get("assignees"), dict) and isinstance(override_map[node_id].get("assignees"), dict):
3978
+ merged["assignees"] = {**deepcopy(node["assignees"]), **deepcopy(override_map[node_id]["assignees"])}
3979
+ if isinstance(node.get("permissions"), dict) and isinstance(override_map[node_id].get("permissions"), dict):
3980
+ merged["permissions"] = {**deepcopy(node["permissions"]), **deepcopy(override_map[node_id]["permissions"])}
3981
+ merged_nodes.append(merged)
3982
+ consumed.add(node_id)
3983
+ else:
3984
+ merged_nodes.append(deepcopy(node))
3985
+ for node in override_nodes:
3986
+ node_id = str(node.get("id") or "")
3987
+ if node_id and node_id not in consumed:
3988
+ merged_nodes.append(deepcopy(node))
3989
+ merged_transitions = deepcopy(override_transitions) if override_transitions else deepcopy(base_transitions)
3990
+ return merged_nodes, merged_transitions
3991
+
3992
+
3993
+ def _align_flow_preset_override_ids(
3994
+ *,
3995
+ base_nodes: list[dict[str, Any]],
3996
+ override_nodes: list[dict[str, Any]],
3997
+ override_transitions: list[dict[str, Any]],
3998
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
3999
+ """Map simple preset skeleton overrides back onto canonical preset node ids.
4000
+
4001
+ This keeps preset-based plans stable when the caller customizes the single
4002
+ approval/fill/copy step semantically but forgets to reuse ids such as
4003
+ `approve_1` or `fill_1`.
4004
+ """
4005
+ patchable_types = {"approve", "fill", "copy"}
4006
+ base_by_type: dict[str, list[str]] = {}
4007
+ override_by_type: dict[str, list[str]] = {}
4008
+ for node in base_nodes:
4009
+ if not isinstance(node, dict):
4010
+ continue
4011
+ node_type = str(node.get("type") or "")
4012
+ node_id = str(node.get("id") or "")
4013
+ if node_type in patchable_types and node_id:
4014
+ base_by_type.setdefault(node_type, []).append(node_id)
4015
+ for node in override_nodes:
4016
+ if not isinstance(node, dict):
4017
+ continue
4018
+ node_type = str(node.get("type") or "")
4019
+ node_id = str(node.get("id") or "")
4020
+ if node_type in patchable_types and node_id:
4021
+ override_by_type.setdefault(node_type, []).append(node_id)
4022
+ replacement_map: dict[str, str] = {}
4023
+ for node_type in patchable_types:
4024
+ base_ids = base_by_type.get(node_type) or []
4025
+ override_ids = override_by_type.get(node_type) or []
4026
+ if len(base_ids) != 1 or len(override_ids) != 1:
4027
+ continue
4028
+ base_id = base_ids[0]
4029
+ override_id = override_ids[0]
4030
+ if override_id == base_id:
4031
+ continue
4032
+ replacement_map[override_id] = base_id
4033
+ if not replacement_map:
4034
+ return deepcopy(override_nodes), deepcopy(override_transitions)
4035
+ aligned_nodes: list[dict[str, Any]] = []
4036
+ for node in override_nodes:
4037
+ if not isinstance(node, dict):
4038
+ continue
4039
+ aligned = deepcopy(node)
4040
+ node_id = str(aligned.get("id") or "")
4041
+ if node_id in replacement_map:
4042
+ aligned["id"] = replacement_map[node_id]
4043
+ aligned_nodes.append(aligned)
4044
+ aligned_transitions: list[dict[str, Any]] = []
4045
+ for transition in override_transitions:
4046
+ if not isinstance(transition, dict):
4047
+ continue
4048
+ aligned = deepcopy(transition)
4049
+ source = str(aligned.get("from") or "")
4050
+ target = str(aligned.get("to") or "")
4051
+ if source in replacement_map:
4052
+ aligned["from"] = replacement_map[source]
4053
+ if target in replacement_map:
4054
+ aligned["to"] = replacement_map[target]
4055
+ aligned_transitions.append(aligned)
4056
+ return aligned_nodes, aligned_transitions
4057
+
4058
+
4059
+ def _extract_directory_items(listed: JSONObject) -> list[dict[str, Any]]:
4060
+ if isinstance(listed.get("items"), list):
4061
+ return [item for item in listed["items"] if isinstance(item, dict)]
4062
+ result = listed.get("result")
4063
+ if isinstance(result, dict) and isinstance(result.get("result"), list):
4064
+ return [item for item in result["result"] if isinstance(item, dict)]
4065
+ if isinstance(result, list):
4066
+ return [item for item in result if isinstance(item, dict)]
4067
+ return []
4068
+
4069
+
2115
4070
  def _build_views_preset(preset: ViewsPreset, field_names: list[str]) -> list[dict[str, Any]]:
2116
4071
  ordered = [name for name in field_names if name]
2117
4072
  if preset == ViewsPreset.status_board:
2118
4073
  group_by = next((name for name in ordered if "状态" in name), ordered[0] if ordered else "")
2119
4074
  return [{"name": "按状态看板", "type": "board", "group_by": group_by, "columns": ordered[:3] or ([group_by] if group_by else [])}]
4075
+ if preset == ViewsPreset.default_gantt:
4076
+ title_like = next((name for name in ordered if "名称" in name or "标题" in name), ordered[0] if ordered else "")
4077
+ start_like = next((name for name in ordered if "开始" in name or "起始" in name), "")
4078
+ end_like = next((name for name in ordered if "结束" in name or "截止" in name or "完成" in name), "")
4079
+ columns = [name for name in (title_like, start_like, end_like) if name]
4080
+ return [
4081
+ {
4082
+ "name": "项目甘特图",
4083
+ "type": "gantt",
4084
+ "columns": columns,
4085
+ "start_field": start_like or None,
4086
+ "end_field": end_like or None,
4087
+ "title_field": title_like or None,
4088
+ }
4089
+ ]
2120
4090
  return [{"name": "全部数据", "type": "table", "columns": ordered[: min(5, len(ordered))]}]
2121
4091
 
2122
4092
 
@@ -2263,13 +4233,20 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
2263
4233
  for view in result:
2264
4234
  if not isinstance(view, dict):
2265
4235
  continue
4236
+ name = view.get("viewgraphName") or view.get("viewName") or view.get("title")
4237
+ view_key = view.get("viewgraphKey") or view.get("viewKey")
4238
+ view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
4239
+ columns = view.get("columnNames") or view.get("columns") or []
4240
+ group_by = view.get("groupBy") or view.get("group_by")
4241
+ if not any((name, view_key, view_type, columns, group_by)):
4242
+ continue
2266
4243
  items.append(
2267
4244
  {
2268
- "name": view.get("viewgraphName") or view.get("viewName") or view.get("title"),
2269
- "view_key": view.get("viewgraphKey") or view.get("viewKey"),
2270
- "type": view.get("viewgraphType") or view.get("type"),
2271
- "column_names": view.get("columnNames") or view.get("columns") or [],
2272
- "group_by": view.get("groupBy") or view.get("group_by"),
4245
+ "name": name,
4246
+ "view_key": view_key,
4247
+ "type": view_type,
4248
+ "columns": columns,
4249
+ "group_by": group_by,
2273
4250
  }
2274
4251
  )
2275
4252
  return items
@@ -2376,8 +4353,16 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
2376
4353
  if node_map[parent_id].get("type") == "branch":
2377
4354
  payload["branch_parent_id"] = parent_id
2378
4355
  payload["branch_index"] = outbound[parent_id].index(node_id) + 1
2379
- if isinstance(node.get("config"), dict) and node["config"]:
2380
- payload["config"] = deepcopy(node["config"])
4356
+ config_payload = deepcopy(node.get("config") or {}) if isinstance(node.get("config"), dict) else {}
4357
+ permissions = node.get("permissions") or {}
4358
+ editable_que_ids = permissions.get("editable_que_ids") or []
4359
+ if editable_que_ids:
4360
+ config_payload["editableQueIds"] = editable_que_ids
4361
+ if config_payload:
4362
+ payload["config"] = config_payload
4363
+ assignees = node.get("assignees") or {}
4364
+ if assignees:
4365
+ payload["assignees"] = deepcopy(assignees)
2381
4366
  internal_nodes.append(payload)
2382
4367
  return {
2383
4368
  "status": "success",
@@ -2385,6 +4370,91 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
2385
4370
  }
2386
4371
 
2387
4372
 
4373
+ def _build_flow_condition_matrix(
4374
+ *,
4375
+ current_fields_by_name: dict[str, dict[str, Any]],
4376
+ node: dict[str, Any],
4377
+ ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
4378
+ if str(node.get("type") or "") != "condition":
4379
+ return [], []
4380
+ raw_groups = node.get("condition_groups") or []
4381
+ if not isinstance(raw_groups, list):
4382
+ return [], []
4383
+ groups: list[list[dict[str, Any]]] = []
4384
+ issues: list[dict[str, Any]] = []
4385
+ for raw_group in raw_groups:
4386
+ if not isinstance(raw_group, list):
4387
+ continue
4388
+ translated_group: list[dict[str, Any]] = []
4389
+ for raw_rule in raw_group:
4390
+ if not isinstance(raw_rule, dict):
4391
+ continue
4392
+ field_name = str(raw_rule.get("field_name") or "").strip()
4393
+ field = current_fields_by_name.get(field_name)
4394
+ if field is None:
4395
+ issues.append(
4396
+ {
4397
+ "kind": "condition_fields",
4398
+ "error_code": "UNKNOWN_FLOW_FIELD",
4399
+ "missing_fields": [field_name] if field_name else [],
4400
+ }
4401
+ )
4402
+ continue
4403
+ translated_group.append(_translate_flow_condition_rule(field=field, rule=raw_rule))
4404
+ if translated_group:
4405
+ groups.append(translated_group)
4406
+ return groups, issues
4407
+
4408
+
4409
+ def _translate_flow_condition_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]:
4410
+ operator = str(rule.get("operator") or "").strip().lower()
4411
+ values = list(rule.get("values") or [])
4412
+ field_type = str(field.get("type") or FieldType.text.value)
4413
+ que_id = _coerce_positive_int(field.get("que_id")) or 0
4414
+ que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
4415
+ base: dict[str, Any] = {
4416
+ "queId": que_id,
4417
+ "queTitle": str(field.get("name") or ""),
4418
+ "queType": que_type,
4419
+ "matchType": MATCH_TYPE_ACCURACY,
4420
+ }
4421
+ if operator == "eq":
4422
+ base["judgeType"] = JUDGE_EQUAL
4423
+ base["judgeValues"] = [_stringify_condition_value(values[0])]
4424
+ elif operator == "neq":
4425
+ base["judgeType"] = JUDGE_UNEQUAL
4426
+ base["judgeValues"] = [_stringify_condition_value(values[0])]
4427
+ elif operator == "in":
4428
+ base["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
4429
+ base["judgeValues"] = [_stringify_condition_value(value) for value in values]
4430
+ elif operator == "contains":
4431
+ base["judgeType"] = JUDGE_FUZZY_MATCH
4432
+ base["judgeValues"] = [_stringify_condition_value(values[0])]
4433
+ elif operator == "gte":
4434
+ base["judgeType"] = JUDGE_GREATER_OR_EQUAL
4435
+ base["judgeValues"] = [_stringify_condition_value(values[0])]
4436
+ elif operator == "lte":
4437
+ base["judgeType"] = JUDGE_LESS_OR_EQUAL
4438
+ base["judgeValues"] = [_stringify_condition_value(values[0])]
4439
+ elif operator == "is_empty":
4440
+ base["judgeType"] = JUDGE_EQUAL
4441
+ base["judgeValues"] = []
4442
+ elif operator == "not_empty":
4443
+ base["judgeType"] = JUDGE_UNEQUAL
4444
+ base["judgeValues"] = []
4445
+ return base
4446
+
4447
+
4448
+ def _stringify_condition_value(value: Any) -> str:
4449
+ if isinstance(value, bool):
4450
+ return "true" if value else "false"
4451
+ if value is None:
4452
+ return ""
4453
+ if isinstance(value, (dict, list)):
4454
+ return json.dumps(value, ensure_ascii=False)
4455
+ return str(value)
4456
+
4457
+
2388
4458
  def _build_view_create_payload(
2389
4459
  *,
2390
4460
  app_key: str,
@@ -2392,15 +4462,20 @@ def _build_view_create_payload(
2392
4462
  schema: dict[str, Any],
2393
4463
  patch: ViewUpsertPatch,
2394
4464
  ordinal: int,
4465
+ view_filters: list[list[dict[str, Any]]],
2395
4466
  ) -> JSONObject:
2396
4467
  entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
2397
- field_ids = [_field_id_for_name(_parse_schema(schema)["fields"], name) for name in patch.columns]
4468
+ parsed_schema = _parse_schema(schema)
4469
+ visible_field_names = _resolve_view_visible_field_names(patch)
4470
+ field_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
4471
+ gantt_config = _build_public_gantt_payload(parsed_schema["fields"], extract_field_map(schema), patch)
2398
4472
  view_spec = ViewSpec(
2399
4473
  view_id=_slugify(patch.name, default=f"view_{uuid4().hex[:6]}"),
2400
4474
  name=patch.name,
2401
4475
  type=patch.type.value,
2402
4476
  field_ids=field_ids,
2403
4477
  group_by_field_id=_field_id_for_name(_parse_schema(schema)["fields"], patch.group_by) if patch.group_by else None,
4478
+ config=gantt_config,
2404
4479
  )
2405
4480
  from ..solution.spec_models import EntitySpec
2406
4481
  from ..solution.compiler.view_compiler import compile_views
@@ -2408,7 +4483,7 @@ def _build_view_create_payload(
2408
4483
  compiled = compile_views(EntitySpec.model_validate({**entity, "views": [view_spec.model_dump(mode="json")]}))[0]
2409
4484
  field_map = extract_field_map(schema)
2410
4485
  payload = deepcopy(compiled["create_payload"])
2411
- visible_que_ids = [field_map[field_name] for field_name in patch.columns if field_name in field_map]
4486
+ visible_que_ids = [field_map[field_name] for field_name in visible_field_names if field_name in field_map]
2412
4487
  payload["appKey"] = app_key
2413
4488
  payload["ordinal"] = ordinal
2414
4489
  payload["viewgraphQueIds"] = visible_que_ids
@@ -2416,19 +4491,19 @@ def _build_view_create_payload(
2416
4491
  payload["auth"] = default_member_auth()
2417
4492
  payload.setdefault("sortType", "defaultSort")
2418
4493
  payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
2419
- payload.setdefault("beingPinNavigate", True)
2420
- payload.setdefault("beingShowCover", False)
2421
- payload.setdefault("defaultRowHigh", "compact")
2422
- payload.setdefault("asosChartVisible", False)
2423
- payload.setdefault("viewgraphLimitType", 1)
2424
- payload.setdefault("viewgraphLimit", [])
2425
- payload.setdefault("buttonConfigDTOList", [])
2426
- if patch.type.value in {"card", "board"}:
4494
+ if patch.type.value in {"card", "board", "gantt"}:
2427
4495
  payload["beingShowTitleQue"] = True
2428
- payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
4496
+ payload["titleQue"] = gantt_config.get("titleQueId") or (visible_que_ids[0] if visible_que_ids else None)
2429
4497
  if patch.type.value == "board":
2430
4498
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2431
- return payload
4499
+ return _hydrate_view_backend_payload(
4500
+ payload=payload,
4501
+ view_type=patch.type.value,
4502
+ visible_que_id_values=visible_que_ids,
4503
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
4504
+ view_filters=view_filters,
4505
+ gantt_payload=gantt_config,
4506
+ )
2432
4507
 
2433
4508
 
2434
4509
  def _build_form_payload_from_existing_schema(
@@ -2551,6 +4626,10 @@ def _pick_view_template_key(existing_views: list[dict[str, Any]], *, desired_typ
2551
4626
 
2552
4627
  def _normalize_view_type_name(value: Any) -> str:
2553
4628
  normalized = str(value or "").strip().lower()
4629
+ if not normalized:
4630
+ return ""
4631
+ if "gantt" in normalized:
4632
+ return "gantt"
2554
4633
  if "board" in normalized:
2555
4634
  return "board"
2556
4635
  if "card" in normalized:
@@ -2565,14 +4644,17 @@ def _build_view_update_payload(
2565
4644
  source_viewgraph_key: str,
2566
4645
  schema: dict[str, Any],
2567
4646
  patch: ViewUpsertPatch,
4647
+ view_filters: list[list[dict[str, Any]]],
2568
4648
  ) -> JSONObject:
2569
4649
  config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
2570
4650
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
2571
4651
  payload = deepcopy(config)
2572
4652
  parsed_schema = _parse_schema(schema)
2573
4653
  field_map = extract_field_map(schema)
2574
- visible_que_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in patch.columns]
2575
- visible_que_id_values = [field_map[name] for name in patch.columns if name in field_map]
4654
+ visible_field_names = _resolve_view_visible_field_names(patch)
4655
+ visible_que_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
4656
+ visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
4657
+ gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
2576
4658
 
2577
4659
  for key in (
2578
4660
  "appKey",
@@ -2600,7 +4682,7 @@ def _build_view_update_payload(
2600
4682
  payload.setdefault("beingShowCover", False)
2601
4683
  payload.setdefault("defaultRowHigh", "compact")
2602
4684
  payload.setdefault("viewgraphLimitType", 1)
2603
- payload.setdefault("viewgraphLimit", [])
4685
+ payload.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
2604
4686
  payload.setdefault("buttonConfigDTOList", [])
2605
4687
 
2606
4688
  normalized_type = patch.type.value
@@ -2620,7 +4702,385 @@ def _build_view_update_payload(
2620
4702
  payload["beingShowTitleQue"] = True
2621
4703
  payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
2622
4704
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2623
- return payload
4705
+ elif normalized_type == "gantt":
4706
+ payload["viewgraphType"] = "ganttView"
4707
+ payload["beingShowTitleQue"] = True
4708
+ payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
4709
+ payload.pop("groupQueId", None)
4710
+ return _hydrate_view_backend_payload(
4711
+ payload=payload,
4712
+ view_type=normalized_type,
4713
+ visible_que_id_values=visible_que_id_values,
4714
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
4715
+ view_filters=view_filters,
4716
+ gantt_payload=gantt_payload,
4717
+ )
4718
+
4719
+
4720
+ def _build_minimal_view_payload(
4721
+ *,
4722
+ app_key: str,
4723
+ schema: dict[str, Any],
4724
+ patch: ViewUpsertPatch,
4725
+ ordinal: int,
4726
+ view_filters: list[list[dict[str, Any]]],
4727
+ ) -> JSONObject:
4728
+ field_map = extract_field_map(schema)
4729
+ parsed_schema = _parse_schema(schema)
4730
+ visible_field_names = _resolve_view_visible_field_names(patch)
4731
+ visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
4732
+ gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
4733
+ payload: JSONObject = {
4734
+ "appKey": app_key,
4735
+ "viewgraphName": patch.name,
4736
+ "viewgraphType": {
4737
+ "table": "tableView",
4738
+ "card": "cardView",
4739
+ "board": "boardView",
4740
+ "gantt": "ganttView",
4741
+ }[patch.type.value],
4742
+ "ordinal": ordinal,
4743
+ "viewgraphQueIds": visible_que_id_values,
4744
+ "viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
4745
+ "auth": default_member_auth(),
4746
+ }
4747
+ if patch.type.value in {"card", "board", "gantt"}:
4748
+ payload["beingShowTitleQue"] = True
4749
+ payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
4750
+ if patch.type.value == "board":
4751
+ payload["groupQueId"] = field_map.get(patch.group_by or "")
4752
+ return _hydrate_view_backend_payload(
4753
+ payload=payload,
4754
+ view_type=patch.type.value,
4755
+ visible_que_id_values=visible_que_id_values,
4756
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
4757
+ view_filters=view_filters,
4758
+ gantt_payload=gantt_payload,
4759
+ )
4760
+
4761
+
4762
+ def _hydrate_view_backend_payload(
4763
+ *,
4764
+ payload: JSONObject,
4765
+ view_type: str,
4766
+ visible_que_id_values: list[int],
4767
+ group_que_id: int | None,
4768
+ view_filters: list[list[dict[str, Any]]] | None = None,
4769
+ gantt_payload: dict[str, Any] | None = None,
4770
+ ) -> JSONObject:
4771
+ data = deepcopy(payload)
4772
+ data.setdefault("beingPinNavigate", True)
4773
+ data.setdefault("beingNeedPass", False)
4774
+ data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
4775
+ data.setdefault("beingShowCover", False)
4776
+ data.setdefault("defaultRowHigh", "compact")
4777
+ data.setdefault("asosChartVisible", False)
4778
+ data.setdefault("viewgraphPass", "")
4779
+ data.setdefault("beingGroupColor", False)
4780
+ data.setdefault("beingShowQueTitle", True)
4781
+ data.setdefault("beingImageAdaption", False)
4782
+ data.setdefault("clippingMode", "default")
4783
+ data.setdefault("frontCoverQueId", None)
4784
+ data.setdefault("viewgraphLimitType", 1)
4785
+ data.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
4786
+ data.setdefault("viewgraphLimitFormula", "")
4787
+ data.setdefault("sortType", "defaultSort")
4788
+ if not data.get("viewgraphSorts"):
4789
+ sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
4790
+ data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
4791
+ data.setdefault("beingAuditRecordVisible", True)
4792
+ data.setdefault("beingQrobotRecordVisible", False)
4793
+ data.setdefault("beingPrintStatus", False)
4794
+ data.setdefault("beingDefaultPrintTplStatus", False)
4795
+ data.setdefault("printTpls", [])
4796
+ data.setdefault("beingCommentStatus", False)
4797
+ data.setdefault("usages", [])
4798
+ data.setdefault("dataPermissionType", "CUSTOM")
4799
+ data.setdefault("dataScope", "ALL")
4800
+ data.setdefault("needPass", False)
4801
+ data.setdefault("beingWorkflowNodeFutureListVisible", True)
4802
+ data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
4803
+ data.setdefault("viewgraphGanttConfigVO", None)
4804
+ data.setdefault("viewgraphHierarchyConfigVO", None)
4805
+ data.setdefault("buttonConfigDTOList", [])
4806
+ if "buttonConfig" not in data:
4807
+ data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
4808
+ if view_type == "table":
4809
+ data["viewgraphType"] = "tableView"
4810
+ data["beingShowTitleQue"] = False
4811
+ data["titleQue"] = None
4812
+ data["groupQueId"] = None
4813
+ elif view_type == "card":
4814
+ data["viewgraphType"] = "cardView"
4815
+ data["beingShowTitleQue"] = True
4816
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
4817
+ data["groupQueId"] = None
4818
+ elif view_type == "board":
4819
+ data["viewgraphType"] = "boardView"
4820
+ data["beingShowTitleQue"] = True
4821
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
4822
+ data["groupQueId"] = group_que_id
4823
+ elif view_type == "gantt":
4824
+ data["viewgraphType"] = "ganttView"
4825
+ data["beingShowTitleQue"] = True
4826
+ data["titleQue"] = (gantt_payload or {}).get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
4827
+ data["groupQueId"] = None
4828
+ data["viewgraphGanttConfigVO"] = deepcopy(gantt_payload) if gantt_payload else None
4829
+ if view_filters is not None:
4830
+ data["viewgraphLimit"] = deepcopy(view_filters)
4831
+ return data
4832
+
4833
+
4834
+ def _resolve_view_visible_field_names(patch: ViewUpsertPatch) -> list[str]:
4835
+ ordered: list[str] = []
4836
+ for value in [*patch.columns, patch.title_field, patch.start_field, patch.end_field, patch.group_by]:
4837
+ name = str(value or "").strip()
4838
+ if name and name not in ordered:
4839
+ ordered.append(name)
4840
+ return ordered
4841
+
4842
+
4843
+ def _build_public_gantt_payload(
4844
+ fields: list[dict[str, Any]],
4845
+ field_map: dict[str, int],
4846
+ patch: ViewUpsertPatch,
4847
+ ) -> dict[str, Any]:
4848
+ if patch.type.value != "gantt":
4849
+ return {}
4850
+ title_field_name = str((patch.title_field or (patch.columns[0] if patch.columns else "")) or "").strip()
4851
+ start_field_name = str(patch.start_field or "").strip()
4852
+ end_field_name = str(patch.end_field or "").strip()
4853
+ return {
4854
+ "titleQueId": field_map.get(title_field_name),
4855
+ "startTimeQueId": field_map.get(start_field_name),
4856
+ "endTimeQueId": field_map.get(end_field_name),
4857
+ "defaultTimeDimension": "week",
4858
+ "ganttGroupVOList": [],
4859
+ "ganttDependencyVO": {
4860
+ "dependencyQueId": None,
4861
+ "predecessorTaskQueId": None,
4862
+ "startEndOptionId": None,
4863
+ "startStartOptionId": None,
4864
+ "endEndOptionId": None,
4865
+ "endStartOptionId": None,
4866
+ },
4867
+ "ganttAutoCalibrationVO": {
4868
+ "autoCalibrationRuleVO": {
4869
+ "startStartBegin": False,
4870
+ "startEndBegin": False,
4871
+ "startEndFinish": False,
4872
+ "endStartBegin": True,
4873
+ "endStartFinish": True,
4874
+ "endEndFinish": False,
4875
+ },
4876
+ "beingAutoCalibration": False,
4877
+ "userAutoCalibration": False,
4878
+ },
4879
+ }
4880
+
4881
+
4882
+ def _build_view_filter_groups(
4883
+ *,
4884
+ current_fields_by_name: dict[str, dict[str, Any]],
4885
+ filters: list[Any],
4886
+ ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
4887
+ translated_rules: list[dict[str, Any]] = []
4888
+ issues: list[dict[str, Any]] = []
4889
+ for raw_rule in filters:
4890
+ if hasattr(raw_rule, "model_dump"):
4891
+ raw_rule = raw_rule.model_dump(mode="json")
4892
+ if not isinstance(raw_rule, dict):
4893
+ continue
4894
+ field_name = str(raw_rule.get("field_name") or "").strip()
4895
+ field = current_fields_by_name.get(field_name)
4896
+ if field is None:
4897
+ issues.append(
4898
+ {
4899
+ "error_code": "UNKNOWN_VIEW_FIELD",
4900
+ "missing_fields": [field_name] if field_name else [],
4901
+ "reason_path": "filters[].field_name",
4902
+ }
4903
+ )
4904
+ continue
4905
+ translated_rule, issue = _translate_view_filter_rule(field=field, rule=raw_rule)
4906
+ if issue:
4907
+ issues.append(issue)
4908
+ continue
4909
+ translated_rules.append(translated_rule)
4910
+ return ([translated_rules] if translated_rules else []), issues
4911
+
4912
+
4913
+ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]:
4914
+ operator = str(rule.get("operator") or "").strip().lower()
4915
+ values = list(rule.get("values") or [])
4916
+ field_type = str(field.get("type") or FieldType.text.value)
4917
+ que_id = _coerce_positive_int(field.get("que_id")) or 0
4918
+ que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
4919
+ judge_values, judge_value_details, issue = _resolve_view_filter_values(field=field, values=values)
4920
+ if issue:
4921
+ return {}, issue
4922
+ payload: dict[str, Any] = {
4923
+ "queId": que_id,
4924
+ "queTitle": str(field.get("name") or ""),
4925
+ "queType": que_type,
4926
+ "matchType": MATCH_TYPE_ACCURACY,
4927
+ "judgeValueDetails": judge_value_details,
4928
+ }
4929
+ if operator == "eq":
4930
+ payload["judgeType"] = JUDGE_EQUAL
4931
+ payload["judgeValues"] = judge_values[:1] if judge_values else []
4932
+ elif operator == "neq":
4933
+ payload["judgeType"] = JUDGE_UNEQUAL
4934
+ payload["judgeValues"] = judge_values[:1] if judge_values else []
4935
+ elif operator == "in":
4936
+ payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
4937
+ payload["judgeValues"] = judge_values
4938
+ elif operator == "contains":
4939
+ payload["judgeType"] = JUDGE_FUZZY_MATCH
4940
+ payload["judgeValues"] = judge_values[:1] if judge_values else []
4941
+ elif operator == "gte":
4942
+ payload["judgeType"] = JUDGE_GREATER_OR_EQUAL
4943
+ payload["judgeValues"] = judge_values[:1] if judge_values else []
4944
+ elif operator == "lte":
4945
+ payload["judgeType"] = JUDGE_LESS_OR_EQUAL
4946
+ payload["judgeValues"] = judge_values[:1] if judge_values else []
4947
+ elif operator == "is_empty":
4948
+ payload["judgeType"] = JUDGE_EQUAL
4949
+ payload["judgeValues"] = []
4950
+ elif operator == "not_empty":
4951
+ payload["judgeType"] = JUDGE_UNEQUAL
4952
+ payload["judgeValues"] = []
4953
+ return payload, None
4954
+
4955
+
4956
+ def _resolve_view_filter_values(
4957
+ *,
4958
+ field: dict[str, Any],
4959
+ values: list[Any],
4960
+ ) -> tuple[list[str], list[dict[str, Any]], dict[str, Any] | None]:
4961
+ field_type = str(field.get("type") or FieldType.text.value)
4962
+ if field_type not in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
4963
+ return [_stringify_condition_value(value) for value in values], _build_view_filter_value_details(values), None
4964
+
4965
+ option_details = [
4966
+ item
4967
+ for item in (field.get("option_details") or [])
4968
+ if isinstance(item, dict) and item.get("id") is not None and item.get("value") is not None
4969
+ ]
4970
+ if not option_details:
4971
+ return [_stringify_condition_value(value) for value in values], _build_view_filter_value_details(values), None
4972
+
4973
+ option_by_value = {str(item.get("value") or "").strip(): item for item in option_details if str(item.get("value") or "").strip()}
4974
+ option_by_id = {str(item.get("id")): item for item in option_details if item.get("id") is not None}
4975
+ resolved_details: list[dict[str, Any]] = []
4976
+ unresolved_values: list[str] = []
4977
+ for value in values:
4978
+ resolved = _resolve_view_filter_option_detail(value=value, option_by_value=option_by_value, option_by_id=option_by_id)
4979
+ if resolved is None:
4980
+ unresolved_values.append(_stringify_condition_value(value))
4981
+ continue
4982
+ resolved_details.append({"id": resolved.get("id"), "value": str(resolved.get("value") or resolved.get("id"))})
4983
+ if unresolved_values:
4984
+ return (
4985
+ [],
4986
+ [],
4987
+ {
4988
+ "error_code": "UNKNOWN_VIEW_FILTER_VALUE",
4989
+ "reason_path": "filters[].values",
4990
+ "missing_fields": [],
4991
+ "allowed_values": {"filter_values": sorted(option_by_value.keys())},
4992
+ "details": {
4993
+ "field_name": str(field.get("name") or ""),
4994
+ "unresolved_values": unresolved_values,
4995
+ },
4996
+ },
4997
+ )
4998
+ return (
4999
+ [str(detail["id"]) for detail in resolved_details if detail.get("id") is not None],
5000
+ [_coerce_view_filter_value_detail(detail) for detail in resolved_details],
5001
+ None,
5002
+ )
5003
+
5004
+
5005
+ def _resolve_view_filter_option_detail(
5006
+ *,
5007
+ value: Any,
5008
+ option_by_value: dict[str, dict[str, Any]],
5009
+ option_by_id: dict[str, dict[str, Any]],
5010
+ ) -> dict[str, Any] | None:
5011
+ if isinstance(value, dict):
5012
+ item_id = value.get("id")
5013
+ if item_id is not None:
5014
+ matched = option_by_id.get(str(item_id))
5015
+ if matched is not None:
5016
+ return matched
5017
+ item_value = str(value.get("value") or "").strip()
5018
+ if item_value:
5019
+ return option_by_value.get(item_value)
5020
+ return None
5021
+ if isinstance(value, int):
5022
+ return option_by_id.get(str(value))
5023
+ normalized = str(value or "").strip()
5024
+ if not normalized:
5025
+ return None
5026
+ return option_by_value.get(normalized) or option_by_id.get(normalized)
5027
+
5028
+
5029
+ def _build_view_filter_value_details(values: list[Any]) -> list[dict[str, Any]]:
5030
+ details: list[dict[str, Any]] = []
5031
+ for value in values:
5032
+ if isinstance(value, dict):
5033
+ item_id = value.get("id")
5034
+ if item_id is None:
5035
+ continue
5036
+ item_value = value.get("value")
5037
+ details.append(_coerce_view_filter_value_detail({"id": item_id, "value": item_value if item_value is not None else str(item_id)}))
5038
+ elif isinstance(value, int):
5039
+ details.append(_coerce_view_filter_value_detail({"id": value, "value": str(value)}))
5040
+ return details
5041
+
5042
+
5043
+ def _coerce_view_filter_value_detail(value: dict[str, Any]) -> dict[str, Any]:
5044
+ item_id = value.get("id")
5045
+ item_value = value.get("value")
5046
+ return {
5047
+ "id": item_id,
5048
+ "value": item_value if item_value is not None else (str(item_id) if item_id is not None else None),
5049
+ "dataValue": value.get("dataValue"),
5050
+ "email": value.get("email"),
5051
+ "optionId": value.get("optionId"),
5052
+ "ordinal": value.get("ordinal"),
5053
+ "otherInfo": value.get("otherInfo"),
5054
+ "pluginValue": value.get("pluginValue"),
5055
+ "queId": value.get("queId"),
5056
+ }
5057
+
5058
+
5059
+ def _normalize_view_filter_groups_for_compare(groups: Any) -> list[list[dict[str, Any]]]:
5060
+ normalized_groups: list[list[dict[str, Any]]] = []
5061
+ for raw_group in groups or []:
5062
+ if not isinstance(raw_group, list):
5063
+ continue
5064
+ normalized_rules: list[dict[str, Any]] = []
5065
+ for raw_rule in raw_group:
5066
+ if not isinstance(raw_rule, dict):
5067
+ continue
5068
+ normalized_details = []
5069
+ for detail in raw_rule.get("judgeValueDetails") or []:
5070
+ if not isinstance(detail, dict) or detail.get("id") is None:
5071
+ continue
5072
+ normalized_details.append({"id": detail.get("id"), "value": str(detail.get("value") or detail.get("id"))})
5073
+ normalized_rules.append(
5074
+ {
5075
+ "queId": _coerce_positive_int(raw_rule.get("queId")) or 0,
5076
+ "judgeType": _coerce_positive_int(raw_rule.get("judgeType")) or 0,
5077
+ "judgeValues": [str(value) for value in (raw_rule.get("judgeValues") or [])],
5078
+ "judgeValueDetails": normalized_details,
5079
+ }
5080
+ )
5081
+ if normalized_rules:
5082
+ normalized_groups.append(normalized_rules)
5083
+ return normalized_groups
2624
5084
 
2625
5085
 
2626
5086
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
@@ -2654,6 +5114,7 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
2654
5114
  request_id = first_error.get("request_id")
2655
5115
  backend_code = first_error.get("backend_code")
2656
5116
  http_status = first_error.get("http_status")
5117
+ lowered_detail = detail_text.lower()
2657
5118
  if "must declare status field" in detail_text:
2658
5119
  return _failed(
2659
5120
  "STATUS_FIELD_REQUIRED",
@@ -2686,14 +5147,45 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
2686
5147
  backend_code=backend_code,
2687
5148
  http_status=http_status,
2688
5149
  )
5150
+ if (
5151
+ "run solution_build_app first" in lowered_detail
5152
+ or "run solution_build_app_flow first" in lowered_detail
5153
+ or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
5154
+ ):
5155
+ return _failed(
5156
+ "FLOW_STAGE_CONTEXT_MISSING",
5157
+ "workflow apply lost the app context required by the internal flow builder",
5158
+ details={
5159
+ "app_key": app_key,
5160
+ "entity_id": entity.get("entity_id"),
5161
+ "existing_fields": entity.get("fields") or [],
5162
+ "internal_detail": detail_text,
5163
+ "stage_result": public_stage_result,
5164
+ },
5165
+ suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
5166
+ request_id=request_id,
5167
+ backend_code=backend_code,
5168
+ http_status=http_status,
5169
+ )
5170
+ message = detail_text or "failed to apply workflow patch"
5171
+ details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
5172
+ public_http_status = http_status
5173
+ if http_status == 404:
5174
+ message = "workflow write route is unavailable for this app in the current route"
5175
+ details["transport_error"] = {
5176
+ "http_status": http_status,
5177
+ "backend_code": backend_code,
5178
+ "category": "http",
5179
+ }
5180
+ public_http_status = None
2689
5181
  return _failed(
2690
5182
  stage_error_code,
2691
- detail_text or "failed to apply workflow patch",
2692
- details={"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result},
5183
+ message,
5184
+ details=details,
2693
5185
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
2694
5186
  request_id=request_id,
2695
5187
  backend_code=backend_code,
2696
- http_status=http_status,
5188
+ http_status=public_http_status,
2697
5189
  )
2698
5190
 
2699
5191