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

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
@@ -134,6 +136,75 @@ class AiBuilderFacade:
134
136
  "match_mode": "exact",
135
137
  }
136
138
 
139
+ def package_create(self, *, profile: str, package_name: str) -> JSONObject:
140
+ requested = str(package_name or "").strip()
141
+ normalized_args = {"package_name": requested}
142
+ if not requested:
143
+ return _failed(
144
+ "PACKAGE_NAME_REQUIRED",
145
+ "package_name is required",
146
+ normalized_args=normalized_args,
147
+ suggested_next_call=None,
148
+ )
149
+ existing = self.package_resolve(profile=profile, package_name=requested)
150
+ if existing.get("status") == "success":
151
+ return {
152
+ "status": "success",
153
+ "error_code": None,
154
+ "recoverable": False,
155
+ "message": "package already exists",
156
+ "normalized_args": normalized_args,
157
+ "missing_fields": [],
158
+ "allowed_values": {},
159
+ "details": {},
160
+ "request_id": None,
161
+ "suggested_next_call": None,
162
+ "noop": True,
163
+ "verification": {"existing_package_reused": True},
164
+ "tag_id": existing.get("tag_id"),
165
+ "tag_name": existing.get("tag_name"),
166
+ }
167
+ if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
168
+ return existing
169
+ try:
170
+ created = self.packages.package_create(profile=profile, payload={"tagName": requested})
171
+ except (QingflowApiError, RuntimeError) as error:
172
+ api_error = _coerce_api_error(error)
173
+ return _failed_from_api_error(
174
+ "PACKAGE_CREATE_FAILED",
175
+ api_error,
176
+ normalized_args=normalized_args,
177
+ details={"package_name": requested},
178
+ suggested_next_call={"tool_name": "package_create", "arguments": {"profile": profile, "package_name": requested}},
179
+ )
180
+ result = created.get("result") if isinstance(created.get("result"), dict) else {}
181
+ tag_id = _coerce_positive_int(result.get("tagId"))
182
+ tag_name = str(result.get("tagName") or requested).strip() or requested
183
+ if tag_id is None:
184
+ resolved = self.package_resolve(profile=profile, package_name=requested)
185
+ if resolved.get("status") == "success":
186
+ tag_id = _coerce_positive_int(resolved.get("tag_id"))
187
+ tag_name = str(resolved.get("tag_name") or tag_name)
188
+ verified = tag_id is not None
189
+ return {
190
+ "status": "success" if verified else "partial_success",
191
+ "error_code": None,
192
+ "recoverable": False,
193
+ "message": "created package" if verified else "created package but could not verify tag id",
194
+ "normalized_args": normalized_args,
195
+ "missing_fields": [],
196
+ "allowed_values": {},
197
+ "details": {},
198
+ "request_id": None,
199
+ "suggested_next_call": None
200
+ if verified
201
+ else {"tool_name": "package_resolve", "arguments": {"profile": profile, "package_name": requested}},
202
+ "noop": False,
203
+ "verification": {"tag_id_verified": verified},
204
+ "tag_id": tag_id,
205
+ "tag_name": tag_name,
206
+ }
207
+
137
208
  def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
138
209
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
139
210
  return {
@@ -209,6 +280,115 @@ class AiBuilderFacade:
209
280
  "attached": attached,
210
281
  }
211
282
 
283
+ def app_release_edit_lock_if_mine(
284
+ self,
285
+ *,
286
+ profile: str,
287
+ app_key: str,
288
+ lock_owner_email: str = "",
289
+ lock_owner_name: str = "",
290
+ ) -> JSONObject:
291
+ normalized_args = {
292
+ "app_key": app_key,
293
+ "lock_owner_email": lock_owner_email,
294
+ "lock_owner_name": lock_owner_name,
295
+ }
296
+ session_profile = self.apps.sessions.get_profile(profile)
297
+ if session_profile is None:
298
+ return _failed(
299
+ "AUTH_REQUIRED",
300
+ "auth profile is required before releasing an app edit lock",
301
+ normalized_args=normalized_args,
302
+ recoverable=False,
303
+ suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
304
+ )
305
+ identity = self._resolve_current_user_identity(profile=profile)
306
+ current_email = str(identity.get("email") or "").strip().lower()
307
+ current_name = str(identity.get("nick_name") or "").strip()
308
+ requested_owner_email = str(lock_owner_email or "").strip().lower()
309
+ requested_owner_name = str(lock_owner_name or "").strip()
310
+ if not requested_owner_email and not requested_owner_name:
311
+ return _failed(
312
+ "EDIT_LOCK_OWNER_UNKNOWN",
313
+ "lock owner could not be verified; refuse to release edit lock blindly",
314
+ normalized_args=normalized_args,
315
+ recoverable=False,
316
+ details={
317
+ "current_user_email": identity.get("email"),
318
+ "current_user_name": identity.get("nick_name"),
319
+ },
320
+ suggested_next_call=None,
321
+ )
322
+ owner_matches = True
323
+ if requested_owner_email:
324
+ owner_matches = bool(current_email) and current_email == requested_owner_email
325
+ elif requested_owner_name:
326
+ owner_matches = bool(current_name) and current_name == requested_owner_name
327
+ if not owner_matches:
328
+ return _failed(
329
+ "EDIT_LOCK_HELD_BY_OTHER_USER",
330
+ "edit lock is owned by another user; refusing to release it",
331
+ normalized_args=normalized_args,
332
+ recoverable=False,
333
+ details={
334
+ "lock_owner_email": requested_owner_email or None,
335
+ "lock_owner_name": requested_owner_name or None,
336
+ "current_user_email": identity.get("email"),
337
+ "current_user_name": identity.get("nick_name"),
338
+ },
339
+ suggested_next_call=None,
340
+ )
341
+ try:
342
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
343
+ except (QingflowApiError, RuntimeError) as error:
344
+ api_error = _coerce_api_error(error)
345
+ return _failed_from_api_error(
346
+ "EDIT_LOCK_RELEASE_FAILED",
347
+ api_error,
348
+ normalized_args=normalized_args,
349
+ details={"app_key": app_key},
350
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
351
+ )
352
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
353
+ try:
354
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
355
+ except (QingflowApiError, RuntimeError) as error:
356
+ api_error = _coerce_api_error(error)
357
+ return _failed_from_api_error(
358
+ "EDIT_LOCK_RELEASE_FAILED",
359
+ api_error,
360
+ normalized_args=normalized_args,
361
+ details={
362
+ "app_key": app_key,
363
+ "edit_version_no": edit_version_no,
364
+ "lock_owner_email": requested_owner_email or None,
365
+ "lock_owner_name": requested_owner_name or None,
366
+ },
367
+ suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
368
+ )
369
+ return {
370
+ "status": "success",
371
+ "error_code": None,
372
+ "recoverable": False,
373
+ "message": "released app edit lock owned by current user",
374
+ "normalized_args": normalized_args,
375
+ "missing_fields": [],
376
+ "allowed_values": {},
377
+ "details": {
378
+ "lock_owner_email": requested_owner_email or None,
379
+ "lock_owner_name": requested_owner_name or None,
380
+ "current_user_email": identity.get("email"),
381
+ "current_user_name": identity.get("nick_name"),
382
+ "edit_version_no": edit_version_no,
383
+ },
384
+ "request_id": None,
385
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
386
+ "noop": False,
387
+ "verification": {"released": True},
388
+ "app_key": app_key,
389
+ "released": True,
390
+ }
391
+
212
392
  def app_resolve(
213
393
  self,
214
394
  *,
@@ -220,8 +400,14 @@ class AiBuilderFacade:
220
400
  if app_key:
221
401
  try:
222
402
  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)})
403
+ except (QingflowApiError, RuntimeError) as exc:
404
+ api_error = _coerce_api_error(exc)
405
+ return _failed_from_api_error(
406
+ "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
407
+ api_error,
408
+ details={"app_key": app_key},
409
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
410
+ )
225
411
  result = base.get("result") if isinstance(base.get("result"), dict) else {}
226
412
  return {
227
413
  "status": "success",
@@ -298,8 +484,30 @@ class AiBuilderFacade:
298
484
  }
299
485
 
300
486
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
301
- state = self._load_app_state(profile=profile, app_key=app_key)
487
+ try:
488
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
489
+ except (QingflowApiError, RuntimeError) as error:
490
+ api_error = _coerce_api_error(error)
491
+ return _failed_from_api_error(
492
+ "APP_READ_FAILED",
493
+ api_error,
494
+ normalized_args={"app_key": app_key},
495
+ details={"app_key": app_key},
496
+ suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
497
+ )
498
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
499
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
302
500
  parsed = state["parsed"]
501
+ verification_hints = _build_verification_hints(
502
+ tag_ids=_coerce_int_list(state["base"].get("tagIds")),
503
+ fields=parsed["fields"],
504
+ layout=parsed["layout"],
505
+ views=_summarize_views(views),
506
+ )
507
+ if views_unavailable:
508
+ verification_hints.append("views_read_unavailable")
509
+ if workflow_unavailable:
510
+ verification_hints.append("workflow_read_unavailable")
303
511
  response = AppReadSummaryResponse(
304
512
  app_key=app_key,
305
513
  title=state["base"].get("formTitle"),
@@ -307,14 +515,9 @@ class AiBuilderFacade:
307
515
  publish_status=state["base"].get("appPublishStatus"),
308
516
  field_count=len(parsed["fields"]),
309
517
  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
- ),
518
+ view_count=len(_summarize_views(views)),
519
+ workflow_enabled=bool(workflow),
520
+ verification_hints=verification_hints,
318
521
  )
319
522
  return {
320
523
  "status": "success",
@@ -333,7 +536,17 @@ class AiBuilderFacade:
333
536
  }
334
537
 
335
538
  def app_read_fields(self, *, profile: str, app_key: str) -> JSONObject:
336
- state = self._load_app_state(profile=profile, app_key=app_key)
539
+ try:
540
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
541
+ except (QingflowApiError, RuntimeError) as error:
542
+ api_error = _coerce_api_error(error)
543
+ return _failed_from_api_error(
544
+ "FIELDS_READ_FAILED",
545
+ api_error,
546
+ normalized_args={"app_key": app_key},
547
+ details={"app_key": app_key},
548
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
549
+ )
337
550
  parsed = state["parsed"]
338
551
  response = AppFieldsReadResponse(
339
552
  app_key=app_key,
@@ -367,7 +580,17 @@ class AiBuilderFacade:
367
580
  }
368
581
 
369
582
  def app_read_layout_summary(self, *, profile: str, app_key: str) -> JSONObject:
370
- state = self._load_app_state(profile=profile, app_key=app_key)
583
+ try:
584
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
585
+ except (QingflowApiError, RuntimeError) as error:
586
+ api_error = _coerce_api_error(error)
587
+ return _failed_from_api_error(
588
+ "LAYOUT_READ_FAILED",
589
+ api_error,
590
+ normalized_args={"app_key": app_key},
591
+ details={"app_key": app_key},
592
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
593
+ )
371
594
  parsed = state["parsed"]
372
595
  layout = parsed["layout"]
373
596
  response = AppLayoutReadResponse(
@@ -393,10 +616,20 @@ class AiBuilderFacade:
393
616
  }
394
617
 
395
618
  def app_read_views_summary(self, *, profile: str, app_key: str) -> JSONObject:
396
- state = self._load_app_state(profile=profile, app_key=app_key)
619
+ try:
620
+ views, _ = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
621
+ except (QingflowApiError, RuntimeError) as error:
622
+ api_error = _coerce_api_error(error)
623
+ return _failed_from_api_error(
624
+ "VIEWS_READ_FAILED",
625
+ api_error,
626
+ normalized_args={"app_key": app_key},
627
+ details={"app_key": app_key},
628
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
629
+ )
397
630
  response = AppViewsReadResponse(
398
631
  app_key=app_key,
399
- views=_summarize_views(state["views"]),
632
+ views=_summarize_views(views),
400
633
  )
401
634
  return {
402
635
  "status": "success",
@@ -415,11 +648,21 @@ class AiBuilderFacade:
415
648
  }
416
649
 
417
650
  def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
418
- state = self._load_app_state(profile=profile, app_key=app_key)
651
+ try:
652
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
653
+ except (QingflowApiError, RuntimeError) as error:
654
+ api_error = _coerce_api_error(error)
655
+ return _failed_from_api_error(
656
+ "FLOW_READ_FAILED",
657
+ api_error,
658
+ normalized_args={"app_key": app_key},
659
+ details={"app_key": app_key},
660
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
661
+ )
419
662
  response = AppFlowReadResponse(
420
663
  app_key=app_key,
421
- enabled=bool(state["workflow"]),
422
- nodes=_summarize_workflow_nodes(state["workflow"]),
664
+ enabled=bool(workflow),
665
+ nodes=_summarize_workflow_nodes(workflow),
423
666
  transitions=[],
424
667
  )
425
668
  return {
@@ -434,7 +677,7 @@ class AiBuilderFacade:
434
677
  "request_id": None,
435
678
  "suggested_next_call": None,
436
679
  "noop": False,
437
- "verification": {"app_exists": True},
680
+ "verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
438
681
  **response.model_dump(mode="json"),
439
682
  }
440
683
 
@@ -452,7 +695,11 @@ class AiBuilderFacade:
452
695
  return target
453
696
  current_fields: list[dict[str, Any]] = []
454
697
  if not bool(target.get("would_create")):
455
- current_fields = self.app_read_fields(profile=profile, app_key=str(target["app_key"])).get("fields", [])
698
+ fields_result = self.app_read_fields(profile=profile, app_key=str(target["app_key"]))
699
+ if fields_result.get("status") == "failed":
700
+ fields_result.setdefault("normalized_args", normalized_args)
701
+ return fields_result
702
+ current_fields = fields_result.get("fields", [])
456
703
  current_by_name = {str(field.get("name") or ""): field for field in current_fields}
457
704
  blocking_issues: list[dict[str, Any]] = []
458
705
  preview_added: list[str] = []
@@ -510,8 +757,12 @@ class AiBuilderFacade:
510
757
 
511
758
  def app_layout_plan(self, *, profile: str, request: LayoutPlanRequest) -> JSONObject:
512
759
  read_fields = self.app_read_fields(profile=profile, app_key=request.app_key)
760
+ if read_fields.get("status") == "failed":
761
+ return read_fields
513
762
  current_names = [str(field.get("name") or "") for field in read_fields.get("fields", []) if field.get("name")]
514
763
  current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
764
+ if current_layout.get("status") == "failed":
765
+ return current_layout
515
766
  requested_sections = [section.model_dump(mode="json") for section in request.sections]
516
767
  if request.preset is not None:
517
768
  requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
@@ -574,7 +825,10 @@ class AiBuilderFacade:
574
825
  transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
575
826
  if request.preset is not None:
576
827
  nodes, transitions = _build_flow_preset(request.preset)
577
- current_fields = self.app_read_fields(profile=profile, app_key=request.app_key).get("fields", [])
828
+ fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
829
+ if fields_result.get("status") == "failed":
830
+ return fields_result
831
+ current_fields = fields_result.get("fields", [])
578
832
  status_field_present = _infer_status_field_id(current_fields) is not None
579
833
  node_types = {str(node.get("type") or "") for node in nodes}
580
834
  if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
@@ -644,7 +898,10 @@ class AiBuilderFacade:
644
898
  }
645
899
 
646
900
  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", [])
901
+ fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
902
+ if fields_result.get("status") == "failed":
903
+ return fields_result
904
+ current_fields = fields_result.get("fields", [])
648
905
  field_names = {str(field.get("name") or "") for field in current_fields}
649
906
  upsert_views = [view.model_dump(mode="json") for view in request.upsert_views]
650
907
  if request.preset is not None:
@@ -728,8 +985,9 @@ class AiBuilderFacade:
728
985
  profile: str,
729
986
  app_key: str = "",
730
987
  package_tag_id: int | None = None,
731
- app_title: str = "",
988
+ app_name: str = "",
732
989
  create_if_missing: bool = False,
990
+ publish: bool = True,
733
991
  add_fields: list[FieldPatch],
734
992
  update_fields: list[FieldUpdatePatch],
735
993
  remove_fields: list[FieldRemovePatch],
@@ -737,8 +995,9 @@ class AiBuilderFacade:
737
995
  normalized_args = {
738
996
  "app_key": app_key,
739
997
  "package_tag_id": package_tag_id,
740
- "app_title": app_title,
998
+ "app_name": app_name,
741
999
  "create_if_missing": create_if_missing,
1000
+ "publish": publish,
742
1001
  "add_fields": [patch.model_dump(mode="json") for patch in add_fields],
743
1002
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
744
1003
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
@@ -746,27 +1005,36 @@ class AiBuilderFacade:
746
1005
  resolved = self._resolve_or_create_target_app(
747
1006
  profile=profile,
748
1007
  app_key=app_key,
749
- app_title=app_title,
1008
+ app_name=app_name,
750
1009
  package_tag_id=package_tag_id,
751
1010
  create_if_missing=create_if_missing,
752
1011
  )
753
1012
  if resolved.get("status") == "failed":
1013
+ if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
1014
+ resolved["normalized_args"] = normalized_args
754
1015
  return resolved
755
1016
  target = ResolvedApp(
756
1017
  app_key=str(resolved["app_key"]),
757
1018
  app_name=str(resolved["app_name"]),
758
1019
  tag_ids=_coerce_int_list(resolved.get("tag_ids")),
759
1020
  )
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 {}
1021
+ schema_readback_delayed = False
1022
+ try:
1023
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
1024
+ except (QingflowApiError, RuntimeError) as error:
1025
+ api_error = _coerce_api_error(error)
1026
+ if not bool(resolved.get("created")) or api_error.http_status != 404:
1027
+ return _failed_from_api_error(
1028
+ "SCHEMA_READBACK_FAILED",
1029
+ api_error,
1030
+ normalized_args=normalized_args,
1031
+ allowed_values={"field_types": [item.value for item in PublicFieldType]},
1032
+ details={"app_key": target.app_key},
1033
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
1034
+ )
1035
+ schema_result = _empty_schema_result(target.app_name)
1036
+ _schema_source = "synthetic_new_app"
1037
+ schema_readback_delayed = True
770
1038
  parsed = _parse_schema(schema_result)
771
1039
  current_fields = parsed["fields"]
772
1040
  layout = parsed["layout"]
@@ -831,7 +1099,7 @@ class AiBuilderFacade:
831
1099
  if not added and not updated and not removed and not bool(resolved.get("created")):
832
1100
  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
1101
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
834
- return {
1102
+ response = {
835
1103
  "status": "success",
836
1104
  "error_code": None,
837
1105
  "recoverable": False,
@@ -851,6 +1119,7 @@ class AiBuilderFacade:
851
1119
  "tag_ids_after": tag_ids_after,
852
1120
  "package_attached": package_attached,
853
1121
  }
1122
+ return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
854
1123
 
855
1124
  payload = _build_form_payload_from_fields(
856
1125
  title=schema_result.get("formTitle") or target.app_name,
@@ -858,6 +1127,11 @@ class AiBuilderFacade:
858
1127
  fields=current_fields,
859
1128
  layout=layout,
860
1129
  )
1130
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1131
+ profile=profile,
1132
+ app_key=target.app_key,
1133
+ current_schema=schema_result,
1134
+ )
861
1135
  try:
862
1136
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
863
1137
  except (QingflowApiError, RuntimeError) as error:
@@ -873,13 +1147,8 @@ class AiBuilderFacade:
873
1147
  },
874
1148
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
875
1149
  )
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",
1150
+ response = {
1151
+ "status": "success",
883
1152
  "error_code": None,
884
1153
  "recoverable": False,
885
1154
  "message": "applied schema patch",
@@ -888,21 +1157,11 @@ class AiBuilderFacade:
888
1157
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
889
1158
  "details": {},
890
1159
  "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
- },
1160
+ "suggested_next_call": None,
902
1161
  "noop": False,
903
1162
  "verification": {
904
- "fields_verified": verification_ok,
905
- "package_attached": package_attached,
1163
+ "fields_verified": False,
1164
+ "package_attached": None,
906
1165
  },
907
1166
  "app_key": target.app_key,
908
1167
  "created": bool(resolved.get("created")),
@@ -911,10 +1170,71 @@ class AiBuilderFacade:
911
1170
  "updated": updated,
912
1171
  "removed": removed,
913
1172
  },
914
- "verified": verification_ok,
915
- "tag_ids_after": tag_ids_after,
916
- "package_attached": package_attached,
1173
+ "verified": False,
1174
+ "tag_ids_after": [],
1175
+ "package_attached": None,
917
1176
  }
1177
+ if schema_readback_delayed:
1178
+ response["verification"]["schema_readback_delayed"] = True
1179
+ response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
1180
+ verification_ok = False
1181
+ tag_ids_after: list[int] = []
1182
+ package_attached: bool | None = None
1183
+ verification_error: QingflowApiError | None = None
1184
+ try:
1185
+ verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
1186
+ verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
1187
+ 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)
1188
+ except (QingflowApiError, RuntimeError) as error:
1189
+ verification_error = _coerce_api_error(error)
1190
+ verification_ok = False
1191
+ try:
1192
+ base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
1193
+ tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
1194
+ package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
1195
+ except (QingflowApiError, RuntimeError) as error:
1196
+ base_error = _coerce_api_error(error)
1197
+ if verification_error is None:
1198
+ verification_error = base_error
1199
+ tag_ids_after = []
1200
+ package_attached = None if package_tag_id is None else False
1201
+ response["verification"]["fields_verified"] = verification_ok
1202
+ response["verification"]["package_attached"] = package_attached
1203
+ response["verified"] = verification_ok
1204
+ response["tag_ids_after"] = tag_ids_after
1205
+ response["package_attached"] = package_attached
1206
+ if package_attached is False:
1207
+ response["suggested_next_call"] = {
1208
+ "tool_name": "package_attach_app",
1209
+ "arguments": {
1210
+ "profile": profile,
1211
+ "tag_id": package_tag_id,
1212
+ "app_key": target.app_key,
1213
+ "app_title": app_name or target.app_name,
1214
+ },
1215
+ }
1216
+ publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
1217
+ if verification_ok and package_attached is not False and not publish_failed:
1218
+ response["status"] = "success"
1219
+ else:
1220
+ response["status"] = "partial_success"
1221
+ if verification_error is not None:
1222
+ response["recoverable"] = True
1223
+ response["error_code"] = response.get("error_code") or (
1224
+ "READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
1225
+ )
1226
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
1227
+ response["request_id"] = response.get("request_id") or verification_error.request_id
1228
+ details = response.get("details")
1229
+ if not isinstance(details, dict):
1230
+ details = {}
1231
+ response["details"] = details
1232
+ details["verification_error"] = {
1233
+ "message": verification_error.message,
1234
+ "http_status": verification_error.http_status,
1235
+ "backend_code": verification_error.backend_code,
1236
+ }
1237
+ return response
918
1238
 
919
1239
  def app_layout_apply(
920
1240
  self,
@@ -923,22 +1243,25 @@ class AiBuilderFacade:
923
1243
  app_key: str,
924
1244
  mode: LayoutApplyMode = LayoutApplyMode.merge,
925
1245
  sections: list[LayoutSectionPatch],
1246
+ publish: bool = True,
926
1247
  ) -> JSONObject:
927
1248
  normalized_args = {
928
1249
  "app_key": app_key,
929
1250
  "mode": mode.value,
930
1251
  "sections": [section.model_dump(mode="json") for section in sections],
1252
+ "publish": publish,
931
1253
  }
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 {}
1254
+ try:
1255
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1256
+ except (QingflowApiError, RuntimeError) as error:
1257
+ api_error = _coerce_api_error(error)
1258
+ return _failed_from_api_error(
1259
+ "LAYOUT_READ_FAILED",
1260
+ api_error,
1261
+ normalized_args=normalized_args,
1262
+ details={"app_key": app_key},
1263
+ suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
1264
+ )
942
1265
  parsed = _parse_schema(schema_result)
943
1266
  current_fields = parsed["fields"]
944
1267
  fields_by_name = {field["name"]: field for field in current_fields}
@@ -996,7 +1319,7 @@ class AiBuilderFacade:
996
1319
  else merged["layout"]
997
1320
  )
998
1321
  if _layouts_equal(parsed["layout"], target_layout):
999
- return {
1322
+ response = {
1000
1323
  "status": "success",
1001
1324
  "error_code": None,
1002
1325
  "recoverable": False,
@@ -1019,10 +1342,16 @@ class AiBuilderFacade:
1019
1342
  },
1020
1343
  "verified": True,
1021
1344
  }
1345
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1022
1346
  payload = _build_form_payload_from_existing_schema(
1023
1347
  current_schema=schema_result,
1024
1348
  layout=target_layout,
1025
1349
  )
1350
+ payload["editVersionNo"] = self._resolve_form_edit_version(
1351
+ profile=profile,
1352
+ app_key=app_key,
1353
+ current_schema=schema_result,
1354
+ )
1026
1355
  applied_layout = target_layout
1027
1356
  fallback_applied = None
1028
1357
  try:
@@ -1035,6 +1364,11 @@ class AiBuilderFacade:
1035
1364
  current_schema=schema_result,
1036
1365
  layout=flattened_layout,
1037
1366
  )
1367
+ fallback_payload["editVersionNo"] = self._resolve_form_edit_version(
1368
+ profile=profile,
1369
+ app_key=app_key,
1370
+ current_schema=schema_result,
1371
+ )
1038
1372
  try:
1039
1373
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
1040
1374
  applied_layout = flattened_layout
@@ -1068,8 +1402,33 @@ class AiBuilderFacade:
1068
1402
  },
1069
1403
  suggested_next_call={"tool_name": "app_layout_plan", "arguments": {"profile": profile, **normalized_args}},
1070
1404
  )
1071
- verified = self.app_read(profile=profile, app_key=app_key, include_raw=False)
1072
- return {
1405
+ verified = self.app_read_layout_summary(profile=profile, app_key=app_key)
1406
+ if verified.get("status") == "failed":
1407
+ response = {
1408
+ "status": "partial_success",
1409
+ "error_code": "LAYOUT_READBACK_PENDING",
1410
+ "recoverable": True,
1411
+ "message": "applied app layout; layout readback pending",
1412
+ "normalized_args": normalized_args,
1413
+ "missing_fields": [],
1414
+ "allowed_values": {"modes": ["merge", "replace"]},
1415
+ "details": {},
1416
+ "request_id": verified.get("request_id"),
1417
+ "suggested_next_call": {"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
1418
+ "noop": False,
1419
+ "verification": {"layout_verified": False, "layout_read_unavailable": True},
1420
+ "app_key": app_key,
1421
+ "layout_diff": {
1422
+ "mode": mode.value,
1423
+ "replaced": mode == LayoutApplyMode.replace,
1424
+ "merged": mode == LayoutApplyMode.merge,
1425
+ "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
1426
+ "fallback_applied": fallback_applied,
1427
+ },
1428
+ "verified": False,
1429
+ }
1430
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1431
+ response = {
1073
1432
  "status": "partial_success" if fallback_applied else "success",
1074
1433
  "error_code": None,
1075
1434
  "recoverable": False,
@@ -1081,7 +1440,7 @@ class AiBuilderFacade:
1081
1440
  "request_id": None,
1082
1441
  "suggested_next_call": None,
1083
1442
  "noop": False,
1084
- "verification": {"layout_verified": verified["layout"] == applied_layout},
1443
+ "verification": {"layout_verified": verified["sections"] == applied_layout.get("sections", [])},
1085
1444
  "app_key": app_key,
1086
1445
  "layout_diff": {
1087
1446
  "mode": mode.value,
@@ -1090,8 +1449,9 @@ class AiBuilderFacade:
1090
1449
  "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
1091
1450
  "fallback_applied": fallback_applied,
1092
1451
  },
1093
- "verified": verified["layout"] == applied_layout,
1452
+ "verified": verified["sections"] == applied_layout.get("sections", []),
1094
1453
  }
1454
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1095
1455
 
1096
1456
  def app_flow_apply(
1097
1457
  self,
@@ -1101,12 +1461,14 @@ class AiBuilderFacade:
1101
1461
  nodes: list[dict[str, Any]],
1102
1462
  transitions: list[dict[str, Any]],
1103
1463
  mode: str = "replace",
1464
+ publish: bool = True,
1104
1465
  ) -> JSONObject:
1105
1466
  normalized_args = {
1106
1467
  "app_key": app_key,
1107
1468
  "mode": mode,
1108
1469
  "nodes": nodes,
1109
1470
  "transitions": transitions,
1471
+ "publish": publish,
1110
1472
  }
1111
1473
  if mode != "replace":
1112
1474
  return _failed(
@@ -1116,16 +1478,18 @@ class AiBuilderFacade:
1116
1478
  allowed_values={"modes": ["replace"]},
1117
1479
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}},
1118
1480
  )
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 {}
1481
+ try:
1482
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1483
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1484
+ except (QingflowApiError, RuntimeError) as error:
1485
+ api_error = _coerce_api_error(error)
1486
+ return _failed_from_api_error(
1487
+ "FLOW_READ_FAILED",
1488
+ api_error,
1489
+ normalized_args=normalized_args,
1490
+ details={"app_key": app_key},
1491
+ suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
1492
+ )
1129
1493
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
1130
1494
  workflow_spec = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
1131
1495
  if workflow_spec.get("status") == "failed":
@@ -1134,7 +1498,8 @@ class AiBuilderFacade:
1134
1498
  workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1135
1499
  return workflow_spec
1136
1500
  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")))
1501
+ current_workflow, _workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
1502
+ current_node_count = len(_summarize_workflow_nodes(current_workflow))
1138
1503
  if current_node_count == desired_node_count and desired_node_count > 0:
1139
1504
  # Lightweight idempotency check for repeat submissions of same simple graph.
1140
1505
  pass
@@ -1146,42 +1511,57 @@ class AiBuilderFacade:
1146
1511
  os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
1147
1512
  try:
1148
1513
  assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
1514
+ manifest = default_manifest()
1515
+ manifest["solution_name"] = base.get("formTitle") or app_key
1516
+ manifest["preferences"]["create_package"] = False
1517
+ manifest["preferences"]["create_portal"] = False
1518
+ manifest["preferences"]["create_navigation"] = False
1519
+ manifest["entities"] = [entity]
1520
+ assembly.set_manifest(manifest)
1521
+ artifacts = default_artifacts()
1522
+ artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
1523
+ assembly.set_artifacts(artifacts)
1524
+ flow_stage_spec = {
1525
+ "solution_name": manifest["solution_name"],
1526
+ "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
1527
+ }
1528
+ assembly.set_stage_spec("app_flow", flow_stage_spec)
1529
+ stage = self.solutions.solution_build_flow(
1530
+ profile=profile,
1531
+ mode="apply",
1532
+ build_id=build_id,
1533
+ flow_spec=flow_stage_spec,
1534
+ publish=False,
1535
+ run_label=None,
1536
+ repair_patch={},
1537
+ )
1149
1538
  finally:
1150
1539
  if previous_build_home is None:
1151
1540
  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
1541
  if stage.get("status") != "success":
1175
1542
  failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
1176
1543
  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}}
1544
+ suggested_next_call = failed.get("suggested_next_call")
1545
+ if not isinstance(suggested_next_call, dict):
1546
+ suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
1547
+ elif suggested_next_call.get("tool_name") == "app_flow_plan":
1548
+ arguments = suggested_next_call.get("arguments")
1549
+ if not isinstance(arguments, dict):
1550
+ arguments = {}
1551
+ arguments.setdefault("profile", profile)
1552
+ arguments.setdefault("app_key", app_key)
1553
+ arguments.setdefault("mode", mode)
1554
+ arguments.setdefault("nodes", nodes)
1555
+ arguments.setdefault("transitions", transitions)
1556
+ suggested_next_call["arguments"] = arguments
1557
+ failed["suggested_next_call"] = suggested_next_call
1178
1558
  return failed
1179
- verified_nodes = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key).get("result")
1180
- return {
1181
- "status": "success",
1559
+ verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
1560
+ response = {
1561
+ "status": "success" if bool(verified_nodes) or not verified_nodes_unavailable else "partial_success",
1182
1562
  "error_code": None,
1183
- "recoverable": False,
1184
- "message": "applied workflow patch",
1563
+ "recoverable": bool(verified_nodes_unavailable),
1564
+ "message": "applied workflow patch" if not verified_nodes_unavailable else "applied workflow patch; flow readback pending",
1185
1565
  "normalized_args": normalized_args,
1186
1566
  "missing_fields": [],
1187
1567
  "allowed_values": {"modes": ["replace"]},
@@ -1189,11 +1569,15 @@ class AiBuilderFacade:
1189
1569
  "request_id": None,
1190
1570
  "suggested_next_call": None,
1191
1571
  "noop": False,
1192
- "verification": {"workflow_verified": bool(verified_nodes)},
1572
+ "verification": {"workflow_verified": bool(verified_nodes), "workflow_read_unavailable": verified_nodes_unavailable},
1193
1573
  "app_key": app_key,
1194
1574
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
1195
1575
  "verified": bool(verified_nodes),
1196
1576
  }
1577
+ if verified_nodes_unavailable:
1578
+ response["error_code"] = "FLOW_READBACK_PENDING"
1579
+ response["suggested_next_call"] = {"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}}
1580
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1197
1581
 
1198
1582
  def app_views_apply(
1199
1583
  self,
@@ -1202,14 +1586,16 @@ class AiBuilderFacade:
1202
1586
  app_key: str,
1203
1587
  upsert_views: list[ViewUpsertPatch],
1204
1588
  remove_views: list[str],
1589
+ publish: bool = True,
1205
1590
  ) -> JSONObject:
1206
1591
  normalized_args = {
1207
1592
  "app_key": app_key,
1208
1593
  "upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
1209
1594
  "remove_views": list(remove_views),
1595
+ "publish": publish,
1210
1596
  }
1211
1597
  if not upsert_views and not remove_views:
1212
- return {
1598
+ response = {
1213
1599
  "status": "success",
1214
1600
  "error_code": None,
1215
1601
  "recoverable": False,
@@ -1226,17 +1612,21 @@ class AiBuilderFacade:
1226
1612
  "views_diff": {"created": [], "updated": [], "removed": []},
1227
1613
  "verified": True,
1228
1614
  }
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 []
1615
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1616
+ try:
1617
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1618
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1619
+ existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
1620
+ except (QingflowApiError, RuntimeError) as error:
1621
+ api_error = _coerce_api_error(error)
1622
+ return _failed_from_api_error(
1623
+ "VIEWS_READ_FAILED",
1624
+ api_error,
1625
+ normalized_args=normalized_args,
1626
+ details={"app_key": app_key},
1627
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1628
+ )
1629
+ existing_views = existing_views or []
1240
1630
  existing_by_name = {}
1241
1631
  for view in existing_views if isinstance(existing_views, list) else []:
1242
1632
  if not isinstance(view, dict):
@@ -1328,6 +1718,33 @@ class AiBuilderFacade:
1328
1718
  created.append(patch.name)
1329
1719
  except (QingflowApiError, RuntimeError) as error:
1330
1720
  api_error = _coerce_api_error(error)
1721
+ if api_error.backend_code == 48104:
1722
+ try:
1723
+ if existing_key or created_key:
1724
+ target_key = created_key or existing_key or ""
1725
+ fallback_payload = _build_minimal_view_payload(
1726
+ app_key=app_key,
1727
+ schema=schema,
1728
+ patch=patch,
1729
+ ordinal=ordinal,
1730
+ )
1731
+ self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
1732
+ if existing_key:
1733
+ updated.append(patch.name)
1734
+ else:
1735
+ created.append(patch.name)
1736
+ continue
1737
+ fallback_payload = _build_minimal_view_payload(
1738
+ app_key=app_key,
1739
+ schema=schema,
1740
+ patch=patch,
1741
+ ordinal=ordinal,
1742
+ )
1743
+ self.views.view_create(profile=profile, payload=fallback_payload)
1744
+ created.append(patch.name)
1745
+ continue
1746
+ except (QingflowApiError, RuntimeError) as fallback_error:
1747
+ api_error = _coerce_api_error(fallback_error)
1331
1748
  if created_key:
1332
1749
  try:
1333
1750
  self.views.view_delete(profile=profile, viewgraph_key=created_key)
@@ -1343,32 +1760,49 @@ class AiBuilderFacade:
1343
1760
  "view_type": patch.type.value,
1344
1761
  "columns": patch.columns,
1345
1762
  "group_by": patch.group_by,
1763
+ "operation": "update" if existing_key or created_key else "create",
1346
1764
  },
1347
1765
  suggested_next_call={
1348
1766
  "tool_name": "app_views_plan",
1349
1767
  "arguments": {"profile": profile, "app_key": app_key},
1350
1768
  },
1351
1769
  )
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)
1770
+ try:
1771
+ verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
1772
+ except (QingflowApiError, RuntimeError) as error:
1773
+ api_error = _coerce_api_error(error)
1774
+ return _failed_from_api_error(
1775
+ "VIEWS_READ_FAILED",
1776
+ api_error,
1777
+ normalized_args=normalized_args,
1778
+ details={"app_key": app_key},
1779
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1780
+ )
1781
+ verified_names = {
1782
+ item.get("viewgraphName") or item.get("viewName") or item.get("title")
1783
+ for item in (verified_view_result or [])
1784
+ if isinstance(item, dict)
1785
+ }
1786
+ 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
1787
  noop = not created and not updated and not removed
1355
- return {
1788
+ response = {
1356
1789
  "status": "success" if verified else "partial_success",
1357
- "error_code": None,
1358
- "recoverable": False,
1359
- "message": "applied view patch",
1790
+ "error_code": None if not verified_views_unavailable else "VIEWS_READBACK_PENDING",
1791
+ "recoverable": bool(verified_views_unavailable),
1792
+ "message": "applied view patch" if not verified_views_unavailable else "applied view patch; views readback pending",
1360
1793
  "normalized_args": normalized_args,
1361
1794
  "missing_fields": [],
1362
1795
  "allowed_values": {"view_types": ["table", "card", "board"]},
1363
1796
  "details": {},
1364
1797
  "request_id": None,
1365
- "suggested_next_call": None,
1798
+ "suggested_next_call": None if not verified_views_unavailable else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1366
1799
  "noop": noop,
1367
- "verification": {"views_verified": verified},
1800
+ "verification": {"views_verified": verified, "views_read_unavailable": verified_views_unavailable},
1368
1801
  "app_key": app_key,
1369
1802
  "views_diff": {"created": created, "updated": updated, "removed": removed},
1370
1803
  "verified": verified,
1371
1804
  }
1805
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1372
1806
 
1373
1807
  def app_publish_verify(
1374
1808
  self,
@@ -1378,12 +1812,33 @@ class AiBuilderFacade:
1378
1812
  expected_package_tag_id: int | None = None,
1379
1813
  ) -> JSONObject:
1380
1814
  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 {}
1815
+ try:
1816
+ base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1817
+ except (QingflowApiError, RuntimeError) as error:
1818
+ api_error = _coerce_api_error(error)
1819
+ return _failed_from_api_error(
1820
+ "APP_READ_FAILED",
1821
+ api_error,
1822
+ normalized_args=normalized_args,
1823
+ details={"app_key": app_key},
1824
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1825
+ )
1382
1826
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
1383
1827
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
1384
1828
  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):
1829
+ try:
1830
+ views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
1831
+ except (QingflowApiError, RuntimeError) as error:
1832
+ api_error = _coerce_api_error(error)
1833
+ return _failed_from_api_error(
1834
+ "VIEWS_READ_FAILED",
1835
+ api_error,
1836
+ normalized_args=normalized_args,
1837
+ details={"app_key": app_key},
1838
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1839
+ )
1840
+ views_before = views_before or []
1841
+ if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
1387
1842
  return {
1388
1843
  "status": "success",
1389
1844
  "error_code": None,
@@ -1418,17 +1873,38 @@ class AiBuilderFacade:
1418
1873
  details={"app_key": app_key, "edit_version_no": edit_version_no},
1419
1874
  suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1420
1875
  )
1421
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1876
+ try:
1877
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1878
+ except (QingflowApiError, RuntimeError) as error:
1879
+ api_error = _coerce_api_error(error)
1880
+ return _failed_from_api_error(
1881
+ "APP_READ_FAILED",
1882
+ api_error,
1883
+ normalized_args=normalized_args,
1884
+ details={"app_key": app_key},
1885
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1886
+ )
1422
1887
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
1423
1888
  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)
1889
+ try:
1890
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
1891
+ except (QingflowApiError, RuntimeError) as error:
1892
+ api_error = _coerce_api_error(error)
1893
+ return _failed_from_api_error(
1894
+ "VIEWS_READ_FAILED",
1895
+ api_error,
1896
+ normalized_args=normalized_args,
1897
+ details={"app_key": app_key},
1898
+ suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
1899
+ )
1900
+ views = views or []
1901
+ views_ok = isinstance(views, list) and not views_unavailable
1426
1902
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
1427
1903
  return {
1428
1904
  "status": "success" if verified else "partial_success",
1429
- "error_code": None,
1430
- "recoverable": False,
1431
- "message": "published and verified app",
1905
+ "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
1906
+ "recoverable": bool(views_unavailable),
1907
+ "message": "published and verified app" if not views_unavailable else "published app; views readback pending",
1432
1908
  "normalized_args": normalized_args,
1433
1909
  "missing_fields": [],
1434
1910
  "allowed_values": {},
@@ -1441,7 +1917,7 @@ class AiBuilderFacade:
1441
1917
  "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
1442
1918
  },
1443
1919
  "noop": False,
1444
- "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok},
1920
+ "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
1445
1921
  "app_key": app_key,
1446
1922
  "published": bool(base.get("appPublishStatus") in {1, 2}),
1447
1923
  "package_attached": package_attached,
@@ -1450,29 +1926,151 @@ class AiBuilderFacade:
1450
1926
  "verified": verified,
1451
1927
  }
1452
1928
 
1453
- def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
1929
+ def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
1930
+ normalized_args = {"app_key": app_key}
1931
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
1932
+ edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
1933
+ try:
1934
+ self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
1935
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
1936
+ except (QingflowApiError, RuntimeError) as error:
1937
+ api_error = _coerce_api_error(error)
1938
+ return _failed_from_api_error(
1939
+ "PUBLISH_FAILED",
1940
+ api_error,
1941
+ normalized_args=normalized_args,
1942
+ details={"app_key": app_key, "edit_version_no": edit_version_no},
1943
+ suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, "app_key": app_key}},
1944
+ )
1945
+ return {
1946
+ "status": "success",
1947
+ "error_code": None,
1948
+ "recoverable": False,
1949
+ "message": "published current app draft",
1950
+ "normalized_args": normalized_args,
1951
+ "missing_fields": [],
1952
+ "allowed_values": {},
1953
+ "details": {"app_key": app_key, "edit_version_no": edit_version_no},
1954
+ "request_id": None,
1955
+ "suggested_next_call": None,
1956
+ "noop": False,
1957
+ "verification": {"published": True},
1958
+ "app_key": app_key,
1959
+ "published": True,
1960
+ }
1961
+
1962
+ def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
1963
+ try:
1964
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
1965
+ except (QingflowApiError, RuntimeError):
1966
+ version_result = {}
1967
+ return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
1968
+
1969
+ def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
1970
+ response["publish_requested"] = publish
1971
+ if not publish:
1972
+ response["published"] = False
1973
+ return response
1974
+ publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key)
1975
+ response["publish_result"] = publish_result
1976
+ response["published"] = bool(publish_result.get("published"))
1977
+ verification = response.get("verification")
1978
+ if not isinstance(verification, dict):
1979
+ verification = {}
1980
+ response["verification"] = verification
1981
+ verification["published"] = bool(publish_result.get("published"))
1982
+ if publish_result.get("status") == "failed":
1983
+ response["status"] = "partial_success"
1984
+ response["error_code"] = response.get("error_code") or publish_result.get("error_code")
1985
+ response["recoverable"] = True
1986
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
1987
+ if not response.get("suggested_next_call"):
1988
+ response["suggested_next_call"] = publish_result.get("suggested_next_call")
1989
+ return response
1990
+
1991
+ def _load_base_schema_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
1454
1992
  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)
1993
+ schema_result, schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1466
1994
  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
1995
  return {
1469
1996
  "base": base_result,
1470
1997
  "schema": schema_result,
1471
1998
  "parsed": _parse_schema(schema_result),
1472
- "views": views.get("result"),
1473
- "workflow": workflow.get("result"),
1999
+ "schema_source": schema_source,
1474
2000
  }
1475
2001
 
2002
+ def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
2003
+ try:
2004
+ views = self.views.view_list_flat(profile=profile, app_key=app_key)
2005
+ except (QingflowApiError, RuntimeError) as error:
2006
+ api_error = _coerce_api_error(error)
2007
+ if api_error.http_status == 404:
2008
+ try:
2009
+ legacy_views = self.views.view_list(profile=profile, app_key=app_key)
2010
+ except (QingflowApiError, RuntimeError) as legacy_error:
2011
+ legacy_api_error = _coerce_api_error(legacy_error)
2012
+ if tolerate_404 and legacy_api_error.http_status == 404:
2013
+ return [], True
2014
+ raise
2015
+ legacy_result = legacy_views.get("result")
2016
+ if _is_view_collection_shape(legacy_result):
2017
+ return _normalize_view_collection(legacy_result), False
2018
+ if tolerate_404:
2019
+ return [], True
2020
+ raise error
2021
+ raise
2022
+ return _normalize_view_collection(views.get("result")), False
2023
+
2024
+ def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
2025
+ try:
2026
+ workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
2027
+ except (QingflowApiError, RuntimeError) as error:
2028
+ api_error = _coerce_api_error(error)
2029
+ if tolerate_404 and api_error.http_status == 404:
2030
+ return [], True
2031
+ raise
2032
+ return workflow.get("result"), False
2033
+
2034
+ def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
2035
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
2036
+ views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
2037
+ workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
2038
+ state["views"] = views
2039
+ state["workflow"] = workflow
2040
+ state["views_unavailable"] = views_unavailable
2041
+ state["workflow_unavailable"] = workflow_unavailable
2042
+ return state
2043
+
2044
+ def _read_schema_with_fallback(self, *, profile: str, app_key: str) -> tuple[dict[str, Any], str]:
2045
+ attempts = (
2046
+ ("draft", True),
2047
+ ("current", None),
2048
+ ("published", False),
2049
+ )
2050
+ last_error: Exception | None = None
2051
+ for label, being_draft in attempts:
2052
+ try:
2053
+ schema = self.apps.app_get_form_schema(
2054
+ profile=profile,
2055
+ app_key=app_key,
2056
+ form_type=1,
2057
+ being_draft=being_draft,
2058
+ being_apply=None,
2059
+ audit_node_id=None,
2060
+ include_raw=True,
2061
+ )
2062
+ result = schema.get("result")
2063
+ return (result if isinstance(result, dict) else {}), label
2064
+ except (QingflowApiError, RuntimeError) as error:
2065
+ api_error = _coerce_api_error(error)
2066
+ last_error = error
2067
+ if api_error.http_status == 404:
2068
+ continue
2069
+ raise
2070
+ if last_error is not None:
2071
+ raise last_error
2072
+ return {}, "unknown"
2073
+
1476
2074
  def _preview_target_app(
1477
2075
  self,
1478
2076
  *,
@@ -1516,33 +2114,72 @@ class AiBuilderFacade:
1516
2114
  *,
1517
2115
  profile: str,
1518
2116
  app_key: str,
1519
- app_title: str,
2117
+ app_name: str,
1520
2118
  package_tag_id: int | None,
1521
2119
  create_if_missing: bool,
1522
2120
  ) -> JSONObject:
1523
2121
  if app_key:
1524
2122
  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)
2123
+ if app_name:
2124
+ resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
1527
2125
  if resolved.get("status") == "success":
1528
2126
  return resolved
1529
2127
  if not create_if_missing:
1530
2128
  return resolved
1531
2129
  elif not create_if_missing:
1532
- return _failed("APP_NAME_REQUIRED", "app_title or app_key is required", suggested_next_call=None)
2130
+ return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
1533
2131
  if not create_if_missing:
1534
2132
  return _failed("APP_NOT_FOUND", "target app was not found", suggested_next_call=None)
1535
2133
  payload: JSONObject = {
1536
- "appName": app_title or "未命名应用",
2134
+ "appName": app_name or "未命名应用",
1537
2135
  "auth": default_member_auth(),
1538
2136
  "tagIds": [],
1539
2137
  }
1540
- created = self.apps.app_create(profile=profile, payload=payload)
2138
+ try:
2139
+ created = self.apps.app_create(profile=profile, payload=payload)
2140
+ except (QingflowApiError, RuntimeError) as error:
2141
+ api_error = _coerce_api_error(error)
2142
+ request_route = self._current_request_route(profile)
2143
+ return _failed_from_api_error(
2144
+ "CREATE_APP_ROUTE_NOT_FOUND" if api_error.http_status == 404 else "APP_CREATE_FAILED",
2145
+ api_error,
2146
+ details={
2147
+ "app_name": app_name,
2148
+ "package_tag_id": package_tag_id,
2149
+ "request_route": request_route,
2150
+ },
2151
+ suggested_next_call=(
2152
+ {"tool_name": "workspace_select", "arguments": {"profile": profile, "ws_id": request_route.get("ws_id")}}
2153
+ if request_route.get("ws_id")
2154
+ else None
2155
+ ),
2156
+ )
1541
2157
  result = created.get("result") if isinstance(created.get("result"), dict) else {}
1542
2158
  new_app_key = str(result.get("appKey") or (result.get("appKeys")[0] if isinstance(result.get("appKeys"), list) and result.get("appKeys") else ""))
1543
2159
  if not new_app_key:
1544
2160
  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 {}
2161
+ try:
2162
+ base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
2163
+ except (QingflowApiError, RuntimeError) as error:
2164
+ api_error = _coerce_api_error(error)
2165
+ if api_error.http_status != 404:
2166
+ return _failed_from_api_error(
2167
+ "APP_CREATE_READBACK_FAILED",
2168
+ api_error,
2169
+ details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
2170
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": new_app_key}},
2171
+ )
2172
+ return {
2173
+ "status": "success",
2174
+ "error_code": None,
2175
+ "recoverable": False,
2176
+ "message": "created app; base readback pending",
2177
+ "suggested_next_call": None,
2178
+ "app_key": new_app_key,
2179
+ "app_name": app_name or "未命名应用",
2180
+ "tag_ids": [],
2181
+ "created": True,
2182
+ }
1546
2183
  return {
1547
2184
  "status": "success",
1548
2185
  "error_code": None,
@@ -1550,11 +2187,56 @@ class AiBuilderFacade:
1550
2187
  "message": "created app",
1551
2188
  "suggested_next_call": None,
1552
2189
  "app_key": new_app_key,
1553
- "app_name": base.get("formTitle") or app_title or "未命名应用",
2190
+ "app_name": base.get("formTitle") or app_name or "未命名应用",
1554
2191
  "tag_ids": _coerce_int_list(base.get("tagIds")),
1555
2192
  "created": True,
1556
2193
  }
1557
2194
 
2195
+ def _current_request_route(self, profile: str) -> JSONObject:
2196
+ session_profile = self.apps.sessions.get_profile(profile)
2197
+ backend_session = self.apps.sessions.get_backend_session(profile)
2198
+ if session_profile is None or backend_session is None:
2199
+ return {}
2200
+ context = BackendRequestContext(
2201
+ base_url=backend_session.base_url,
2202
+ token=backend_session.token,
2203
+ ws_id=session_profile.selected_ws_id,
2204
+ qf_version=backend_session.qf_version,
2205
+ qf_version_source=backend_session.qf_version_source,
2206
+ )
2207
+ describe_route = getattr(self.apps.backend, "describe_route", None)
2208
+ route = describe_route(context) if callable(describe_route) else None
2209
+ payload = dict(route) if isinstance(route, dict) else {}
2210
+ payload.setdefault("ws_id", session_profile.selected_ws_id)
2211
+ return payload
2212
+
2213
+ def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
2214
+ session_profile = self.apps.sessions.get_profile(profile)
2215
+ backend_session = self.apps.sessions.get_backend_session(profile)
2216
+ current_email = str((session_profile.email if session_profile else None) or "").strip()
2217
+ current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
2218
+ if current_email or current_name or session_profile is None or backend_session is None:
2219
+ return {"email": current_email or None, "nick_name": current_name or None}
2220
+ try:
2221
+ user_info = self.apps.backend.request(
2222
+ "GET",
2223
+ BackendRequestContext(
2224
+ base_url=backend_session.base_url,
2225
+ token=backend_session.token,
2226
+ ws_id=session_profile.selected_ws_id,
2227
+ qf_version=backend_session.qf_version,
2228
+ qf_version_source=backend_session.qf_version_source,
2229
+ ),
2230
+ "/user",
2231
+ )
2232
+ except (QingflowApiError, RuntimeError):
2233
+ return {"email": current_email or None, "nick_name": current_name or None}
2234
+ if not isinstance(user_info, dict):
2235
+ return {"email": current_email or None, "nick_name": current_name or None}
2236
+ resolved_email = str(user_info.get("email") or "").strip() or None
2237
+ resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
2238
+ return {"email": resolved_email, "nick_name": resolved_name}
2239
+
1558
2240
  def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
1559
2241
  detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
1560
2242
  result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
@@ -1619,18 +2301,49 @@ def _failed_from_api_error(
1619
2301
  suggested_next_call: JSONObject | None = None,
1620
2302
  recoverable: bool = True,
1621
2303
  ) -> JSONObject:
2304
+ effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
2305
+ public_message = _public_error_message(effective_error_code, error)
2306
+ public_http_status = None if error.http_status == 404 else error.http_status
2307
+ merged_details = dict(details or {})
2308
+ if error.backend_code == 40074:
2309
+ owner = _extract_edit_lock_owner(error.message)
2310
+ merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
2311
+ merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
2312
+ app_key = None
2313
+ if isinstance(normalized_args, dict):
2314
+ app_key = normalized_args.get("app_key")
2315
+ if not app_key and isinstance(details, dict):
2316
+ app_key = details.get("app_key")
2317
+ if isinstance(app_key, str) and app_key.strip():
2318
+ suggested_next_call = {
2319
+ "tool_name": "app_release_edit_lock_if_mine",
2320
+ "arguments": {
2321
+ "app_key": app_key,
2322
+ "lock_owner_name": owner.get("lock_owner_name") or "",
2323
+ "lock_owner_email": owner.get("lock_owner_email") or "",
2324
+ },
2325
+ }
2326
+ if error.http_status is not None or error.backend_code is not None:
2327
+ merged_details.setdefault(
2328
+ "transport_error",
2329
+ {
2330
+ "http_status": error.http_status,
2331
+ "backend_code": error.backend_code,
2332
+ "category": error.category,
2333
+ },
2334
+ )
1622
2335
  return _failed(
1623
- error_code,
1624
- error.message,
2336
+ effective_error_code,
2337
+ public_message,
1625
2338
  recoverable=recoverable,
1626
2339
  normalized_args=normalized_args,
1627
2340
  missing_fields=missing_fields,
1628
2341
  allowed_values=allowed_values,
1629
- details=details,
2342
+ details=merged_details,
1630
2343
  suggested_next_call=suggested_next_call,
1631
2344
  request_id=error.request_id,
1632
2345
  backend_code=error.backend_code,
1633
- http_status=error.http_status,
2346
+ http_status=public_http_status,
1634
2347
  )
1635
2348
 
1636
2349
 
@@ -1669,6 +2382,52 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
1669
2382
  return QingflowApiError(category="runtime", message=str(error))
1670
2383
 
1671
2384
 
2385
+ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
2386
+ if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
2387
+ owner = _extract_edit_lock_owner(error.message)
2388
+ owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
2389
+ if owner_label:
2390
+ return f"app is currently locked by active editor {owner_label}"
2391
+ return "app is currently locked by another active editor session"
2392
+ if error.http_status != 404:
2393
+ return error.message
2394
+ mapping = {
2395
+ "APP_READ_FAILED": "app base or schema is unavailable in the current route",
2396
+ "FIELDS_READ_FAILED": "app fields are unavailable in the current route",
2397
+ "LAYOUT_READ_FAILED": "layout resource is unavailable for this app in the current route",
2398
+ "VIEWS_READ_FAILED": "views resource is unavailable for this app in the current route",
2399
+ "FLOW_READ_FAILED": "workflow resource is unavailable for this app in the current route",
2400
+ "SCHEMA_READBACK_FAILED": "schema was written but schema readback is unavailable in the current route",
2401
+ "CREATE_APP_ROUTE_NOT_FOUND": "create app route is unavailable in the current workspace route",
2402
+ "APP_CREATE_READBACK_FAILED": "app was created but base readback is unavailable in the current route",
2403
+ "PACKAGE_ATTACH_FAILED": "package attachment could not be verified in the current route",
2404
+ "PUBLISH_FAILED": "publish route is unavailable in the current route",
2405
+ "VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
2406
+ "LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
2407
+ "SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
2408
+ "EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
2409
+ }
2410
+ return mapping.get(error_code, "requested builder resource is unavailable in the current route")
2411
+
2412
+
2413
+ def _extract_edit_lock_owner(message: str) -> JSONObject:
2414
+ text = str(message or "").strip()
2415
+ if not text:
2416
+ return {"lock_owner_name": None, "lock_owner_email": None}
2417
+ patterns = [
2418
+ r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
2419
+ r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
2420
+ ]
2421
+ for pattern in patterns:
2422
+ match = re.search(pattern, text)
2423
+ if match:
2424
+ return {
2425
+ "lock_owner_name": match.groupdict().get("name", "").strip() or None,
2426
+ "lock_owner_email": match.groupdict().get("email", "").strip() or None,
2427
+ }
2428
+ return {"lock_owner_name": None, "lock_owner_email": None}
2429
+
2430
+
1672
2431
  def _coerce_positive_int(value: Any) -> int | None:
1673
2432
  if isinstance(value, bool) or value is None:
1674
2433
  return None
@@ -1696,6 +2455,34 @@ def _coerce_int_list(values: Any) -> list[int]:
1696
2455
  return result
1697
2456
 
1698
2457
 
2458
+ def _normalize_view_collection(values: Any) -> list[dict[str, Any]]:
2459
+ if isinstance(values, list):
2460
+ return [item for item in values if isinstance(item, dict)]
2461
+ if isinstance(values, dict):
2462
+ for key in ("list", "viewList", "views", "result"):
2463
+ candidate = values.get(key)
2464
+ if isinstance(candidate, list):
2465
+ return [item for item in candidate if isinstance(item, dict)]
2466
+ return []
2467
+
2468
+
2469
+ def _is_view_collection_shape(values: Any) -> bool:
2470
+ if isinstance(values, list):
2471
+ return True
2472
+ if isinstance(values, dict):
2473
+ return any(isinstance(values.get(key), list) for key in ("list", "viewList", "views", "result"))
2474
+ return False
2475
+
2476
+
2477
+ def _empty_schema_result(title: str) -> dict[str, Any]:
2478
+ return {
2479
+ "formTitle": title,
2480
+ "editVersionNo": 1,
2481
+ "formQues": [],
2482
+ "questionRelations": [],
2483
+ }
2484
+
2485
+
1699
2486
  def _slugify(text: str, *, default: str) -> str:
1700
2487
  normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(text or ""))
1701
2488
  collapsed = "_".join(part for part in normalized.split("_") if part)
@@ -2377,19 +3164,17 @@ def _build_view_create_payload(
2377
3164
  payload["auth"] = default_member_auth()
2378
3165
  payload.setdefault("sortType", "defaultSort")
2379
3166
  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
3167
  if patch.type.value in {"card", "board"}:
2388
3168
  payload["beingShowTitleQue"] = True
2389
3169
  payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
2390
3170
  if patch.type.value == "board":
2391
3171
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2392
- return payload
3172
+ return _hydrate_view_backend_payload(
3173
+ payload=payload,
3174
+ view_type=patch.type.value,
3175
+ visible_que_id_values=visible_que_ids,
3176
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3177
+ )
2393
3178
 
2394
3179
 
2395
3180
  def _build_form_payload_from_existing_schema(
@@ -2581,7 +3366,109 @@ def _build_view_update_payload(
2581
3366
  payload["beingShowTitleQue"] = True
2582
3367
  payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
2583
3368
  payload["groupQueId"] = field_map.get(patch.group_by or "")
2584
- return payload
3369
+ return _hydrate_view_backend_payload(
3370
+ payload=payload,
3371
+ view_type=normalized_type,
3372
+ visible_que_id_values=visible_que_id_values,
3373
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3374
+ )
3375
+
3376
+
3377
+ def _build_minimal_view_payload(
3378
+ *,
3379
+ app_key: str,
3380
+ schema: dict[str, Any],
3381
+ patch: ViewUpsertPatch,
3382
+ ordinal: int,
3383
+ ) -> JSONObject:
3384
+ field_map = extract_field_map(schema)
3385
+ visible_que_id_values = [field_map[name] for name in patch.columns if name in field_map]
3386
+ payload: JSONObject = {
3387
+ "appKey": app_key,
3388
+ "viewgraphName": patch.name,
3389
+ "viewgraphType": {
3390
+ "table": "tableView",
3391
+ "card": "cardView",
3392
+ "board": "boardView",
3393
+ }[patch.type.value],
3394
+ "ordinal": ordinal,
3395
+ "viewgraphQueIds": visible_que_id_values,
3396
+ "viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
3397
+ "auth": default_member_auth(),
3398
+ }
3399
+ if patch.type.value in {"card", "board"}:
3400
+ payload["beingShowTitleQue"] = True
3401
+ payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3402
+ if patch.type.value == "board":
3403
+ payload["groupQueId"] = field_map.get(patch.group_by or "")
3404
+ return _hydrate_view_backend_payload(
3405
+ payload=payload,
3406
+ view_type=patch.type.value,
3407
+ visible_que_id_values=visible_que_id_values,
3408
+ group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
3409
+ )
3410
+
3411
+
3412
+ def _hydrate_view_backend_payload(
3413
+ *,
3414
+ payload: JSONObject,
3415
+ view_type: str,
3416
+ visible_que_id_values: list[int],
3417
+ group_que_id: int | None,
3418
+ ) -> JSONObject:
3419
+ data = deepcopy(payload)
3420
+ data.setdefault("beingPinNavigate", True)
3421
+ data.setdefault("beingNeedPass", False)
3422
+ data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
3423
+ data.setdefault("beingShowCover", False)
3424
+ data.setdefault("defaultRowHigh", "compact")
3425
+ data.setdefault("asosChartVisible", False)
3426
+ data.setdefault("viewgraphPass", "")
3427
+ data.setdefault("beingGroupColor", False)
3428
+ data.setdefault("beingShowQueTitle", True)
3429
+ data.setdefault("beingImageAdaption", False)
3430
+ data.setdefault("clippingMode", "default")
3431
+ data.setdefault("frontCoverQueId", None)
3432
+ data.setdefault("viewgraphLimitType", 1)
3433
+ data.setdefault("viewgraphLimit", [])
3434
+ data.setdefault("viewgraphLimitFormula", "")
3435
+ data.setdefault("sortType", "defaultSort")
3436
+ if not data.get("viewgraphSorts"):
3437
+ sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
3438
+ data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
3439
+ data.setdefault("beingAuditRecordVisible", True)
3440
+ data.setdefault("beingQrobotRecordVisible", False)
3441
+ data.setdefault("beingPrintStatus", False)
3442
+ data.setdefault("beingDefaultPrintTplStatus", False)
3443
+ data.setdefault("printTpls", [])
3444
+ data.setdefault("beingCommentStatus", False)
3445
+ data.setdefault("usages", [])
3446
+ data.setdefault("dataPermissionType", "CUSTOM")
3447
+ data.setdefault("dataScope", "ALL")
3448
+ data.setdefault("needPass", False)
3449
+ data.setdefault("beingWorkflowNodeFutureListVisible", True)
3450
+ data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
3451
+ data.setdefault("viewgraphGanttConfigVO", None)
3452
+ data.setdefault("viewgraphHierarchyConfigVO", None)
3453
+ data.setdefault("buttonConfigDTOList", [])
3454
+ if "buttonConfig" not in data:
3455
+ data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
3456
+ if view_type == "table":
3457
+ data["viewgraphType"] = "tableView"
3458
+ data["beingShowTitleQue"] = False
3459
+ data["titleQue"] = None
3460
+ data["groupQueId"] = None
3461
+ elif view_type == "card":
3462
+ data["viewgraphType"] = "cardView"
3463
+ data["beingShowTitleQue"] = True
3464
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3465
+ data["groupQueId"] = None
3466
+ elif view_type == "board":
3467
+ data["viewgraphType"] = "boardView"
3468
+ data["beingShowTitleQue"] = True
3469
+ data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
3470
+ data["groupQueId"] = group_que_id
3471
+ return data
2585
3472
 
2586
3473
 
2587
3474
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
@@ -2615,6 +3502,7 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
2615
3502
  request_id = first_error.get("request_id")
2616
3503
  backend_code = first_error.get("backend_code")
2617
3504
  http_status = first_error.get("http_status")
3505
+ lowered_detail = detail_text.lower()
2618
3506
  if "must declare status field" in detail_text:
2619
3507
  return _failed(
2620
3508
  "STATUS_FIELD_REQUIRED",
@@ -2647,14 +3535,45 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
2647
3535
  backend_code=backend_code,
2648
3536
  http_status=http_status,
2649
3537
  )
3538
+ if (
3539
+ "run solution_build_app first" in lowered_detail
3540
+ or "run solution_build_app_flow first" in lowered_detail
3541
+ or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
3542
+ ):
3543
+ return _failed(
3544
+ "FLOW_STAGE_CONTEXT_MISSING",
3545
+ "workflow apply lost the app context required by the internal flow builder",
3546
+ details={
3547
+ "app_key": app_key,
3548
+ "entity_id": entity.get("entity_id"),
3549
+ "existing_fields": entity.get("fields") or [],
3550
+ "internal_detail": detail_text,
3551
+ "stage_result": public_stage_result,
3552
+ },
3553
+ suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
3554
+ request_id=request_id,
3555
+ backend_code=backend_code,
3556
+ http_status=http_status,
3557
+ )
3558
+ message = detail_text or "failed to apply workflow patch"
3559
+ details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
3560
+ public_http_status = http_status
3561
+ if http_status == 404:
3562
+ message = "workflow write route is unavailable for this app in the current route"
3563
+ details["transport_error"] = {
3564
+ "http_status": http_status,
3565
+ "backend_code": backend_code,
3566
+ "category": "http",
3567
+ }
3568
+ public_http_status = None
2650
3569
  return _failed(
2651
3570
  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},
3571
+ message,
3572
+ details=details,
2654
3573
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
2655
3574
  request_id=request_id,
2656
3575
  backend_code=backend_code,
2657
- http_status=http_status,
3576
+ http_status=public_http_status,
2658
3577
  )
2659
3578
 
2660
3579