@josephyan/qingflow-app-user-mcp 0.2.0-beta.1 → 0.2.0-beta.11

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