@qingflow-tech/qingflow-cli 1.1.43-beta.3 → 1.1.43-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-app-form/SKILL.md +12 -9
  5. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +15 -13
  6. package/src/qingflow_mcp/app_form_compat.py +11 -27
  7. package/src/qingflow_mcp/app_form_validator.py +298 -35
  8. package/src/qingflow_mcp/builder_facade/app_form.py +777 -150
  9. package/src/qingflow_mcp/builder_facade/app_form_schema.py +164 -8
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +5 -0
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +299 -239
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +82 -27
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +82 -27
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +201 -111
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +1594 -0
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
  35. package/src/qingflow_mcp/builder_facade/service.py +3 -2
  36. package/src/qingflow_mcp/cli/commands/builder.py +2 -0
  37. package/src/qingflow_mcp/response_trim.py +2 -0
  38. package/src/qingflow_mcp/server_app_builder.py +9 -4
  39. package/src/qingflow_mcp/tools/ai_builder_tools.py +13 -3
@@ -8,18 +8,20 @@ import re
8
8
  from typing import Any, Iterable
9
9
  from urllib.parse import unquote_plus
10
10
 
11
- from ..app_form_compat import normalize_app_form_declaration, validate_app_form_create_icon
11
+ from ..app_form_compat import normalize_app_form_declaration
12
12
  from ..errors import QingflowApiError, is_auth_like_error
13
13
  from ..json_types import JSONObject
14
14
  from ..solution.compiler.form_compiler import build_options, build_question, default_member_auth
15
- from ..solution.compiler.icon_utils import encode_workspace_icon
15
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
16
16
  from ..solution.spec_models import FieldType
17
17
  from ..tools.app_tools import AppTools
18
18
  from ..tools.package_tools import PackageTools
19
19
  from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
20
20
  from .service import (
21
+ _coerce_any_int,
21
22
  _compile_code_block_binding_fields,
22
23
  _compile_q_linker_binding_fields,
24
+ _encrypt_question_id,
23
25
  _field_to_question,
24
26
  _looks_like_backend_encoded_formula,
25
27
  _normalize_formula_defaults_for_save,
@@ -288,11 +290,39 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
288
290
  "displayedFieldType": displayed.get("queType"),
289
291
  "matchRuleType": relation.get("matchRuleType"),
290
292
  "matchRules": relation.get("matchRules"),
291
- "matchRuleFormula": relation.get("matchRuleFormula"),
292
293
  }
294
+ formula = str(relation.get("matchRuleFormula") or "").strip()
295
+ if formula:
296
+ result["matchRuleFormula"] = formula
293
297
  return _without_none(result)
294
298
 
295
299
 
300
+ def _canonical_attachment_default_values(value: Any) -> JSONObject | None:
301
+ if not isinstance(value, dict):
302
+ return None
303
+ values = []
304
+ for item in value.get("values") or []:
305
+ if not isinstance(item, dict):
306
+ continue
307
+ url = str(item.get("value") or "").strip()
308
+ if not url:
309
+ data_value = str(item.get("dataValue") or "").strip()
310
+ url = data_value if "://" in data_value else ""
311
+ if not url:
312
+ continue
313
+ file_name = str(item.get("otherInfo") or item.get("valueStr") or "").strip()
314
+ values.append(
315
+ _without_none(
316
+ {
317
+ "value": url,
318
+ "otherInfo": file_name or None,
319
+ "pluginValue": item.get("pluginValue"),
320
+ }
321
+ )
322
+ )
323
+ return {"values": values} if values else None
324
+
325
+
296
326
  def _selector(que_id: Any, name: Any) -> JSONObject:
297
327
  result: JSONObject = {}
298
328
  normalized_id = _positive_int(que_id)
@@ -361,6 +391,135 @@ def _canonical_equal(left: JSONObject, right: JSONObject) -> bool:
361
391
  return _semantic(left) == _semantic(right)
362
392
 
363
393
 
394
+ def _iter_backend_questions(value: Any) -> Iterable[JSONObject]:
395
+ if isinstance(value, list):
396
+ for item in value:
397
+ yield from _iter_backend_questions(item)
398
+ elif isinstance(value, dict):
399
+ if "queType" in value:
400
+ yield value
401
+ yield from _iter_backend_questions(value.get("innerQuestions"))
402
+ yield from _iter_backend_questions(value.get("subQuestions"))
403
+
404
+
405
+ def _normalize_published_scope_omissions(form: JSONObject) -> JSONObject:
406
+ """Allow published GET responses that omit custom member/department scopes."""
407
+ result = deepcopy(form)
408
+ for question in _iter_backend_questions(result.get("formQues")):
409
+ if (
410
+ _positive_int(question.get("memberSelectScopeType")) == 2
411
+ and not isinstance(question.get("memberSelectScope"), dict)
412
+ ):
413
+ question["memberSelectScopeType"] = 1
414
+ question["memberSelectScope"] = {}
415
+ if (
416
+ _positive_int(question.get("deptSelectScopeType")) == 2
417
+ and not isinstance(question.get("deptSelectScope"), dict)
418
+ ):
419
+ question["deptSelectScopeType"] = 1
420
+ question["deptSelectScope"] = {}
421
+ return result
422
+
423
+
424
+ def _without_selectable_scopes(resource: JSONObject) -> JSONObject:
425
+ """Remove only selectable scope values from a published-state comparison."""
426
+ result = deepcopy(resource)
427
+ fields = _iter_fields(result.get("spec", {}).get("body") or [])
428
+ for field in _iter_field_tree(fields):
429
+ config = field.get("config") if isinstance(field.get("config"), dict) else None
430
+ if config is None:
431
+ continue
432
+ config.pop("memberScope", None)
433
+ config.pop("departmentScope", None)
434
+ return result
435
+
436
+
437
+ def _published_field_map(resource: JSONObject) -> dict[tuple[str, Any], JSONObject]:
438
+ fields = _iter_field_tree(_iter_fields(resource.get("spec", {}).get("body") or []))
439
+ result: dict[tuple[str, Any], JSONObject] = {}
440
+ for field in fields:
441
+ que_id = _positive_int(field.get("queId"))
442
+ key = ("id", que_id) if que_id is not None else ("name", (_field_type(field), field.get("name")))
443
+ result[key] = field
444
+ return result
445
+
446
+
447
+ def _matching_published_field(field: JSONObject, published_fields: dict[tuple[str, Any], JSONObject]) -> JSONObject | None:
448
+ que_id = _positive_int(field.get("queId"))
449
+ if que_id is not None and ("id", que_id) in published_fields:
450
+ return published_fields[("id", que_id)]
451
+ return published_fields.get(("name", (_field_type(field), field.get("name"))))
452
+
453
+
454
+ def _normalize_published_get_equivalents(draft: JSONObject, published: JSONObject) -> tuple[JSONObject, JSONObject]:
455
+ """Normalize values that the published GET endpoint omits or materializes."""
456
+ draft_result = _without_selectable_scopes(draft)
457
+ published_result = _without_selectable_scopes(published)
458
+ published_fields = _published_field_map(published_result)
459
+ for draft_field in _published_field_map(draft_result).values():
460
+ published_field = _matching_published_field(draft_field, published_fields)
461
+ if published_field is None:
462
+ continue
463
+ field_type = _field_type(draft_field)
464
+ draft_config = draft_field.get("config") if isinstance(draft_field.get("config"), dict) else {}
465
+ published_config = published_field.get("config") if isinstance(published_field.get("config"), dict) else {}
466
+
467
+ if field_type == "member":
468
+ draft_default = draft_config.get("defaultMembers")
469
+ published_default = published_config.get("defaultMembers")
470
+ if isinstance(draft_default, dict) and isinstance(published_default, dict):
471
+ # Published GET resolves department defaults into runtime members.
472
+ for key in ("departments", "includeSubDeparts"):
473
+ if key in draft_default:
474
+ published_default[key] = deepcopy(draft_default[key])
475
+
476
+ if field_type == "data_relation":
477
+ draft_filters = draft_config.get("filters") if isinstance(draft_config.get("filters"), dict) else {}
478
+ published_filters = published_config.get("filters") if isinstance(published_config.get("filters"), dict) else {}
479
+ draft_groups = draft_filters.get("groups") if isinstance(draft_filters.get("groups"), list) else []
480
+ published_groups = published_filters.get("groups") if isinstance(published_filters.get("groups"), list) else []
481
+ for draft_group, published_group in zip(draft_groups, published_groups):
482
+ if not isinstance(draft_group, list) or not isinstance(published_group, list):
483
+ continue
484
+ for draft_rule, published_rule in zip(draft_group, published_group):
485
+ if not isinstance(draft_rule, dict) or not isinstance(published_rule, dict):
486
+ continue
487
+ draft_source = draft_rule.get("sourceField") if isinstance(draft_rule.get("sourceField"), dict) else {}
488
+ published_source = published_rule.get("sourceField") if isinstance(published_rule.get("sourceField"), dict) else {}
489
+ same_source = _positive_int(draft_source.get("queId")) == _positive_int(published_source.get("queId"))
490
+ if same_source and draft_source.get("name") and not published_source.get("name"):
491
+ published_source["name"] = draft_source["name"]
492
+ draft_operand = draft_rule.get("operand") if isinstance(draft_rule.get("operand"), dict) else {}
493
+ published_operand = published_rule.get("operand") if isinstance(published_rule.get("operand"), dict) else {}
494
+ if (
495
+ same_source
496
+ and draft_rule.get("operator") == published_rule.get("operator")
497
+ and draft_operand.get("kind") == published_operand.get("kind") == "literal"
498
+ and draft_operand.get("values")
499
+ and published_operand.get("values") == []
500
+ ):
501
+ published_operand["values"] = deepcopy(draft_operand["values"])
502
+ # The backend intentionally emits sortConfig only for beingDraft=true.
503
+ if "sort" in draft_config and "sort" not in published_config:
504
+ published_config["sort"] = deepcopy(draft_config["sort"])
505
+
506
+ if field_type == "subtable":
507
+ if draft_config.get("tableRowsLimit") in {None, 200} and published_config.get("tableRowsLimit") in {None, 200}:
508
+ draft_config.pop("tableRowsLimit", None)
509
+ published_config.pop("tableRowsLimit", None)
510
+
511
+ if field_type in {"number", "amount"}:
512
+ if draft_config.get("currencyStatus") in {None, False} and published_config.get("currencyStatus") in {None, False}:
513
+ draft_config.pop("currencyStatus", None)
514
+ published_config.pop("currencyStatus", None)
515
+ return draft_result, published_result
516
+
517
+
518
+ def _published_equal(draft: JSONObject, published: JSONObject) -> bool:
519
+ comparable_draft, comparable_published = _normalize_published_get_equivalents(draft, published)
520
+ return _canonical_equal(comparable_draft, comparable_published)
521
+
522
+
364
523
  def _layout_semantic(body: list[Any]) -> list[JSONObject]:
365
524
  def row_layout(row: Any) -> JSONObject:
366
525
  fields = row.get("fields") if isinstance(row, dict) else []
@@ -368,7 +527,7 @@ def _layout_semantic(body: list[Any]) -> list[JSONObject]:
368
527
  "fields": [
369
528
  {
370
529
  "name": field.get("name"),
371
- "type": field.get("type"),
530
+ "questionType": _field_type(field),
372
531
  "width": field.get("width"),
373
532
  }
374
533
  for field in fields or []
@@ -407,6 +566,16 @@ def _field_semantic(value: JSONObject) -> Any:
407
566
  return _semantic({key: item for key, item in value.items() if key != "width"})
408
567
 
409
568
 
569
+ def _field_type(field: JSONObject) -> str:
570
+ """Read the AppForm field type while accepting pre-rename declarations."""
571
+ return str(field.get("questionType") or field.get("type") or "")
572
+
573
+
574
+ def _field_required(field: JSONObject) -> bool:
575
+ """Read the AppForm required flag while accepting pre-rename declarations."""
576
+ return bool(field.get("beingRequired") if "beingRequired" in field else field.get("required"))
577
+
578
+
410
579
  def _validate_payload_limits(value: Any) -> None:
411
580
  stack = [(value, 1)]
412
581
  while stack:
@@ -437,6 +606,9 @@ class AppFormNormalizer:
437
606
  relations = [item for item in form.get("questionRelations") or [] if isinstance(item, dict)]
438
607
  select_scope_relations = [item for item in form.get("selectScopeRelations") or [] if isinstance(item, dict)]
439
608
  field_names = self._field_index(form)
609
+ definitions = context.complete_schema.get("$defs") if isinstance(context.complete_schema.get("$defs"), dict) else {}
610
+ relation_properties = definitions.get("RelationConfig", {}).get("properties", {}) if isinstance(definitions.get("RelationConfig"), dict) else {}
611
+ supports_reference_default_formula = "defaultValueFormula" in relation_properties
440
612
  title_id = _positive_int(form.get("dataTitleQueId"))
441
613
  cover_id = _positive_int(form.get("dataCoverQueId"))
442
614
  body: list[JSONObject] = []
@@ -449,7 +621,7 @@ class AppFormNormalizer:
449
621
  if section_id is None:
450
622
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
451
623
  rows = [
452
- {"fields": [self._field(question, relations, select_scope_relations, title_id, cover_id, field_names) for question in inner if isinstance(question, dict)]}
624
+ {"fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in inner if isinstance(question, dict)]}
453
625
  for inner in section.get("innerQuestions") or []
454
626
  if isinstance(inner, list) and inner
455
627
  ]
@@ -457,15 +629,15 @@ class AppFormNormalizer:
457
629
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
458
630
  body.append({"kind": "section", "sectionId": str(section_id), "title": str(section.get("queTitle") or ""), "rows": rows})
459
631
  else:
460
- body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, title_id, cover_id, field_names) for question in row if isinstance(question, dict)]})
632
+ body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in row if isinstance(question, dict)]})
461
633
  resource = {
462
634
  "apiVersion": context.api_version,
463
635
  "appKey": app_key,
464
636
  "spec": {
465
637
  "appName": str(form.get("formTitle") or ""),
466
- "description": form.get("formDesc") or None,
467
- "theme": form.get("formTheme"),
468
- "style": self._style(form.get("formStyle")),
638
+ "appDescription": form.get("formDesc") or None,
639
+ "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
640
+ "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
469
641
  "attachments": self._attachments(form.get("formAttach")),
470
642
  "serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
471
643
  "body": body,
@@ -478,9 +650,8 @@ class AppFormNormalizer:
478
650
  question: JSONObject,
479
651
  relations: list[JSONObject],
480
652
  select_scope_relations: list[JSONObject],
481
- title_id: int | None,
482
- cover_id: int | None,
483
653
  field_names: dict[int, str],
654
+ supports_reference_default_formula: bool,
484
655
  ) -> JSONObject:
485
656
  que_id = _positive_int(question.get("queId"))
486
657
  if que_id is None:
@@ -506,9 +677,9 @@ class AppFormNormalizer:
506
677
  )
507
678
  field: JSONObject = {
508
679
  "name": str(question.get("queTitle") or ""),
509
- "type": field_type,
510
- "required": bool(question.get("required")),
511
- "description": question.get("queHint") or None,
680
+ "questionType": field_type,
681
+ "beingRequired": bool(question.get("required")),
682
+ "questionDescription": question.get("queHint") or None,
512
683
  }
513
684
  default_type = _positive_int(question.get("queDefaultType"))
514
685
  if default_type is not None and (
@@ -526,16 +697,12 @@ class AppFormNormalizer:
526
697
  )
527
698
  if "queDefaultValue" in question and raw_default_value is not None and not isinstance(raw_default_value, (dict, list)):
528
699
  default_value = question.get("queDefaultValue")
529
- field["defaultValue"] = _decode_formula_default(default_value) if default_type == 3 else deepcopy(default_value)
700
+ field["defaultValue"] = _decode_formula_default(default_value)
530
701
  if que_id is not None:
531
702
  field["queId"] = que_id
532
703
  width = _positive_int(question.get("queWidth"))
533
704
  if width is not None:
534
705
  field["width"] = width
535
- if title_id is not None and que_id == title_id:
536
- field["dataTitle"] = True
537
- if cover_id is not None and que_id == cover_id:
538
- field["dataCover"] = True
539
706
  if field_type in {"text", "long_text", "link", "phone", "email"}:
540
707
  config = _without_none({
541
708
  "minWordCount": _canonical_unlimited(question.get("minWordCount")),
@@ -643,7 +810,7 @@ class AppFormNormalizer:
643
810
  "maxFileLimit": question.get("maxFileLimit"),
644
811
  "canScan": question.get("canScan"),
645
812
  "scanType": question.get("scanType"),
646
- "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
813
+ "defaultValues": _canonical_attachment_default_values(question.get("queDefaultValues")),
647
814
  })
648
815
  elif field_type == "address":
649
816
  field["config"] = _without_none({
@@ -659,30 +826,31 @@ class AppFormNormalizer:
659
826
  display_id = _positive_int(reference.get("referQueId"))
660
827
  display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
661
828
  visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
662
- field["config"] = _without_none(
663
- {
664
- "targetAppKey": reference.get("referAppKey"),
665
- "relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
666
- "displayField": _require_selector(
667
- display_id,
668
- (display or {}).get("queTitle"),
669
- message="relation display field cannot be normalized",
829
+ relation_config: JSONObject = {
830
+ "targetAppKey": reference.get("referAppKey"),
831
+ "relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
832
+ "displayField": _require_selector(
833
+ display_id,
834
+ (display or {}).get("queTitle"),
835
+ message="relation display field cannot be normalized",
836
+ details={"queId": que_id},
837
+ ),
838
+ "visibleFields": [
839
+ _require_selector(
840
+ item.get("queId"),
841
+ item.get("queTitle"),
842
+ message="relation visible field cannot be normalized",
670
843
  details={"queId": que_id},
671
- ),
672
- "visibleFields": [
673
- _require_selector(
674
- item.get("queId"),
675
- item.get("queTitle"),
676
- message="relation visible field cannot be normalized",
677
- details={"queId": que_id},
678
- )
679
- for item in visible
680
- ],
681
- "beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
682
- "defaultValueMatchRules": reference.get("defaultValueMatchRules"),
683
- "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
684
- }
685
- )
844
+ )
845
+ for item in visible
846
+ ],
847
+ "beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
848
+ "defaultValueMatchRules": reference.get("defaultValueMatchRules"),
849
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
850
+ }
851
+ if supports_reference_default_formula:
852
+ relation_config["defaultValueFormula"] = reference.get("defaultValueFormula")
853
+ field["config"] = _without_none(relation_config)
686
854
  elif field_type == "q_linker":
687
855
  field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
688
856
  elif field_type == "code_block":
@@ -691,14 +859,12 @@ class AppFormNormalizer:
691
859
  field["config"] = self._data_relation_config(question, relations)
692
860
  elif field_type == "subtable":
693
861
  field["subfields"] = [
694
- self._field(item, relations, select_scope_relations, None, None, field_names)
862
+ self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
695
863
  for item in question.get("subQuestions") or []
696
864
  if isinstance(item, dict)
697
865
  ]
698
866
  for subfield in field["subfields"]:
699
867
  subfield.pop("width", None)
700
- subfield.pop("dataTitle", None)
701
- subfield.pop("dataCover", None)
702
868
  field["config"] = _without_none({
703
869
  "tableRowsLimit": question.get("tableRowsLimit"),
704
870
  "tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
@@ -708,12 +874,6 @@ class AppFormNormalizer:
708
874
  })
709
875
  return field
710
876
 
711
- @staticmethod
712
- def _style(value: Any) -> JSONObject | None:
713
- if not isinstance(value, dict) or not any(value.get(key) is not None for key in ("bgImage", "bgColor", "opacity", "headerImage", "headerColor", "fontColor")):
714
- return None
715
- return {key: value.get(key) for key in ("bgImage", "bgColor", "opacity", "headerImage", "headerColor", "fontColor") if key in value}
716
-
717
877
  @staticmethod
718
878
  def _attachments(value: Any) -> list[JSONObject]:
719
879
  result = [
@@ -812,7 +972,14 @@ class AppFormNormalizer:
812
972
  if not isinstance(item, dict):
813
973
  continue
814
974
  if _positive_int(item.get("type")) == 2:
815
- inputs.append({"field": _selector(item.get("value"), None), "key": str(item.get("key") or ""), "source": source})
975
+ input_que_id = _positive_int(item.get("value"))
976
+ inputs.append(
977
+ {
978
+ "field": _selector(input_que_id, field_names.get(input_que_id or -1)),
979
+ "key": str(item.get("key") or ""),
980
+ "source": source,
981
+ }
982
+ )
816
983
  else:
817
984
  static[key].append({"key": str(item.get("key") or ""), "value": item.get("value")})
818
985
  request_values = {
@@ -828,9 +995,19 @@ class AppFormNormalizer:
828
995
  def replace_reference(match: re.Match[str]) -> str:
829
996
  name = match.group("name").strip()
830
997
  if name:
998
+ token = match.group("token").strip()
999
+ matching_ids = [
1000
+ que_id
1001
+ for que_id, field_name in field_names.items()
1002
+ if field_name == name and _encrypt_question_id(que_id) == token
1003
+ ]
831
1004
  json_path_inputs.setdefault(
832
1005
  name,
833
- {"field": {"name": name}, "key": name, "source": "json_path"},
1006
+ {
1007
+ "field": _selector(matching_ids[0] if len(matching_ids) == 1 else None, name),
1008
+ "key": name,
1009
+ "source": "json_path",
1010
+ },
834
1011
  )
835
1012
  return "{{" + name + "}}"
836
1013
 
@@ -973,7 +1150,7 @@ class AppFormNormalizer:
973
1150
  if not bool(sort.get("status")):
974
1151
  result["sort"] = {"enabled": False}
975
1152
  elif _positive_int(sort.get("queId")):
976
- result["sort"] = {"enabled": True, "by": {"kind": "field", "field": _selector(sort.get("queId"), sort.get("queTitle"))}, "direction": "asc" if sort.get("ascend") else "desc"}
1153
+ result["sort"] = {"enabled": True, "by": {"kind": "field", "field": _selector(sort.get("queId"), None)}, "direction": "asc" if sort.get("ascend") else "desc"}
977
1154
  elif str(sort.get("qlinkerAlias") or "").strip():
978
1155
  producer = _require_selector(
979
1156
  alias.get("queId"),
@@ -994,12 +1171,12 @@ class AppFormNormalizer:
994
1171
  else:
995
1172
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation sort cannot be normalized", "NORMALIZE", details={"queId": que_id})
996
1173
  button = question.get("customButtonConfig") if isinstance(question.get("customButtonConfig"), dict) else None
997
- if button is not None:
1174
+ if button is not None and bool(button.get("status")):
998
1175
  detail = button.get("customButtonDetailInfo") if isinstance(button.get("customButtonDetailInfo"), dict) else {}
999
1176
  button_id = _positive_int(detail.get("buttonId")) or _positive_int(button.get("buttonId"))
1000
- if bool(button.get("status")) and button_id is None:
1177
+ if button_id is None:
1001
1178
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "enabled data_relation custom button has no buttonId", "NORMALIZE", details={"queId": que_id})
1002
- result["customButton"] = {"enabled": bool(button.get("status")), **({"buttonId": button_id} if bool(button.get("status")) and button_id else {})}
1179
+ result["customButton"] = {"enabled": True, "buttonId": button_id}
1003
1180
  return result
1004
1181
 
1005
1182
  @staticmethod
@@ -1013,7 +1190,16 @@ class AppFormNormalizer:
1013
1190
  }
1014
1191
  match_type = _positive_int(value.get("matchType")) or 1
1015
1192
  if match_type == 1:
1016
- condition["operand"] = {"kind": "literal", "values": deepcopy(value.get("judgeValues") or [])}
1193
+ details = value.get("judgeValueDetails")
1194
+ if isinstance(details, list):
1195
+ literal_values = [
1196
+ str(item.get("value") if item.get("value") is not None else item.get("id"))
1197
+ for item in details
1198
+ if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
1199
+ ]
1200
+ else:
1201
+ literal_values = deepcopy(value.get("judgeValues") or [])
1202
+ condition["operand"] = {"kind": "literal", "values": literal_values}
1017
1203
  elif match_type == 2:
1018
1204
  detail = value.get("judgeQueDetail") if isinstance(value.get("judgeQueDetail"), dict) else {}
1019
1205
  condition["operand"] = {
@@ -1103,6 +1289,62 @@ class AppFormCompiler:
1103
1289
  details={"field": name},
1104
1290
  )
1105
1291
  field["queId"] = que_id
1292
+ field_type = _field_type(field)
1293
+ alias_binding = {
1294
+ "code_block": ("codeBlockBinding", "codeBlockConfig", "resultAliasPath"),
1295
+ "q_linker": ("qLinkerBinding", "remoteLookupConfig", "resultFormatPath"),
1296
+ }.get(field_type)
1297
+ if alias_binding is not None:
1298
+ binding_key, backend_config_key, backend_aliases_key = alias_binding
1299
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
1300
+ binding = config.get(binding_key) if isinstance(config.get(binding_key), dict) else {}
1301
+ outputs = binding.get("outputs") if isinstance(binding.get("outputs"), list) else []
1302
+ backend_config = question.get(backend_config_key) if isinstance(question.get(backend_config_key), dict) else {}
1303
+ backend_aliases = [
1304
+ item
1305
+ for item in backend_config.get(backend_aliases_key) or []
1306
+ if isinstance(item, dict)
1307
+ ]
1308
+ for output_index, output in enumerate(outputs):
1309
+ if not isinstance(output, dict):
1310
+ continue
1311
+ alias_name = str(output.get("alias") or "").strip()
1312
+ alias_path = str(output.get("path") or "").strip()
1313
+ matches = [
1314
+ item
1315
+ for item in backend_aliases
1316
+ if str(item.get("aliasName") or "").strip() == alias_name
1317
+ and str(item.get("aliasPath") or "").strip() == alias_path
1318
+ ]
1319
+ if len(matches) != 1:
1320
+ raise AppFormFailure(
1321
+ "RELATION_REBIND_FAILED",
1322
+ "identity write did not return a unique output alias identity",
1323
+ "IDENTITY_REBOUND",
1324
+ details={
1325
+ "field": name,
1326
+ "fieldType": field_type,
1327
+ "outputIndex": output_index,
1328
+ "alias": alias_name,
1329
+ "path": alias_path,
1330
+ "matches": len(matches),
1331
+ },
1332
+ )
1333
+ alias_id = _positive_int(matches[0].get("aliasId"))
1334
+ if alias_id is None:
1335
+ raise AppFormFailure(
1336
+ "RELATION_REBIND_FAILED",
1337
+ "identity write returned an output alias without aliasId",
1338
+ "IDENTITY_REBOUND",
1339
+ details={
1340
+ "field": name,
1341
+ "fieldType": field_type,
1342
+ "outputIndex": output_index,
1343
+ "alias": alias_name,
1344
+ "path": alias_path,
1345
+ },
1346
+ )
1347
+ output["aliasId"] = alias_id
1106
1348
  subquestions = [item for item in question.get("subQuestions") or [] if isinstance(item, dict)]
1107
1349
  for subfield in field.get("subfields") or []:
1108
1350
  if not isinstance(subfield, dict):
@@ -1169,17 +1411,18 @@ class AppFormCompiler:
1169
1411
  ) -> JSONObject:
1170
1412
  resource = self.bind_backend_identities(form=form, resource=declaration)
1171
1413
  spec = resource["spec"]
1414
+ field_names = AppFormNormalizer._field_index(form)
1415
+ title_id = _positive_int(form.get("dataTitleQueId"))
1416
+ cover_id = _positive_int(form.get("dataCoverQueId"))
1172
1417
  spec.setdefault("appName", str(form.get("formTitle") or ""))
1173
- spec.setdefault("description", form.get("formDesc") or None)
1174
- spec.setdefault("theme", form.get("formTheme"))
1175
- spec.setdefault("style", AppFormNormalizer._style(form.get("formStyle")))
1418
+ spec.setdefault("appDescription", form.get("formDesc") or None)
1419
+ spec.setdefault("dataTitleField", _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None)
1420
+ spec.setdefault("dataCoverField", _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None)
1176
1421
  spec.setdefault("attachments", AppFormNormalizer._attachments(form.get("formAttach")))
1177
1422
  spec.setdefault(
1178
1423
  "serialNumber",
1179
1424
  AppFormNormalizer._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
1180
1425
  )
1181
- spec.pop("icon", None)
1182
- spec.pop("color", None)
1183
1426
  resource["apiVersion"] = context.api_version
1184
1427
  resource["appKey"] = app_key
1185
1428
  resource.pop("packageId", None)
@@ -1189,23 +1432,28 @@ class AppFormCompiler:
1189
1432
  current_spec = current["spec"]
1190
1433
  declared_spec = declaration["spec"]
1191
1434
  target_spec = deepcopy(current_spec)
1192
- for key in ("appName", "description", "theme", "attachments", "serialNumber"):
1435
+ for key in ("appName", "appDescription", "dataTitleField", "dataCoverField", "attachments", "serialNumber"):
1193
1436
  if key in declared_spec:
1194
1437
  target_spec[key] = deepcopy(declared_spec[key])
1195
- if "style" in declared_spec:
1196
- if declared_spec["style"] is None:
1197
- target_spec["style"] = None
1198
- else:
1199
- merged = deepcopy(current_spec.get("style") or {})
1200
- merged.update(deepcopy(declared_spec["style"]))
1201
- target_spec["style"] = merged or None
1202
1438
  target_spec["body"] = self._target_body(current_spec.get("body") or [], declared_spec.get("body") or [])
1203
- self._validate_target(target_spec["body"])
1439
+ target_fields = list(_iter_fields(target_spec["body"]))
1440
+ for key in ("dataTitleField", "dataCoverField"):
1441
+ if key in declared_spec or not isinstance(target_spec.get(key), dict):
1442
+ continue
1443
+ selector_id = _positive_int(target_spec[key].get("queId"))
1444
+ selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
1445
+ if len(selected) == 1:
1446
+ target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
1447
+ data_title, data_cover = self._validate_target(target_spec)
1448
+ target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
1449
+ target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
1204
1450
  return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
1205
1451
 
1206
1452
  def preflight(self, *, profile: str, app_key: str, declaration: JSONObject, current_form: JSONObject | None) -> None:
1207
- body = declaration.get("spec", {}).get("body") or []
1208
- self._validate_target(body)
1453
+ declaration = self._resolve_declared_field_names(declaration, current_form)
1454
+ spec = declaration.get("spec", {})
1455
+ body = spec.get("body") or []
1456
+ self._validate_target(spec, allow_missing_data_title=current_form is not None)
1209
1457
  fields = list(_iter_fields(body))
1210
1458
  templates = self._question_templates(current_form or {})
1211
1459
  internal_fields = [self._internal_field(field, templates=templates) for field in fields]
@@ -1215,16 +1463,17 @@ class AppFormCompiler:
1215
1463
  internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form or {})
1216
1464
  relation_context = deepcopy(current_form or {})
1217
1465
  relation_context["questionRelations"] = relations
1218
- internal_fields, _ = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
1466
+ internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
1467
+ self._validate_unique_relation_targets(relations, internal_fields)
1219
1468
  except (KeyError, ValueError) as error:
1220
1469
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
1221
1470
  self._assign_subfield_temp_ids(internal_fields)
1222
1471
  for field in fields:
1223
1472
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
1224
- if field.get("type") == "relation":
1473
+ if _field_type(field) == "relation":
1225
1474
  template = templates.get(_positive_int(field.get("queId")) or -1, {}).get("referenceConfig")
1226
1475
  self._reference_config(profile, config, template if isinstance(template, dict) else None)
1227
- elif field.get("type") == "q_linker":
1476
+ elif _field_type(field) == "q_linker":
1228
1477
  for index, item in enumerate((config.get("qLinkerBinding") or {}).get("inputs") or []):
1229
1478
  if not isinstance(item, dict) or item.get("source") != "json_path":
1230
1479
  continue
@@ -1236,11 +1485,51 @@ class AppFormCompiler:
1236
1485
  "CONTRACT_PRECHECK",
1237
1486
  details={"key": item.get("key"), "field": selected.get("name")},
1238
1487
  )
1239
- elif field.get("type") == "data_relation" and "customButton" in config:
1488
+ elif _field_type(field) == "data_relation" and "customButton" in config:
1240
1489
  template = templates.get(_positive_int(field.get("queId")) or -1)
1241
1490
  self._data_relation_custom_button(config.get("customButton") or {}, template)
1242
1491
  self._data_relation_relations(profile, app_key, fields, internal_fields)
1243
1492
 
1493
+ @staticmethod
1494
+ def _resolve_declared_field_names(
1495
+ declaration: JSONObject,
1496
+ current_form: JSONObject | None,
1497
+ ) -> JSONObject:
1498
+ """Fill names for ID-only updates before compilation consumes field names."""
1499
+ result = deepcopy(declaration)
1500
+ current_names = AppFormNormalizer._field_index(current_form or {})
1501
+
1502
+ def visit(field: Any, path: str) -> None:
1503
+ if not isinstance(field, dict):
1504
+ return
1505
+ if not str(field.get("name") or "").strip():
1506
+ que_id = _positive_int(field.get("queId"))
1507
+ name = current_names.get(que_id or 0)
1508
+ if name:
1509
+ field["name"] = name
1510
+ else:
1511
+ raise AppFormFailure(
1512
+ "INVALID_SPEC",
1513
+ "field name is required unless queId identifies an existing field",
1514
+ "CONTRACT_PRECHECK",
1515
+ details={"path": path, "queId": field.get("queId")},
1516
+ )
1517
+ for index, child in enumerate(field.get("subfields") or []):
1518
+ visit(child, f"{path}/subfields/{index}")
1519
+
1520
+ spec = result.get("spec") if isinstance(result.get("spec"), dict) else {}
1521
+ body = spec.get("body") or []
1522
+ for block_index, block in enumerate(body):
1523
+ if not isinstance(block, dict):
1524
+ continue
1525
+ rows = [block] if block.get("kind") == "row" else block.get("rows") or []
1526
+ for row_index, row in enumerate(rows):
1527
+ if not isinstance(row, dict):
1528
+ continue
1529
+ for field_index, field in enumerate(row.get("fields") or []):
1530
+ visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
1531
+ return result
1532
+
1244
1533
  @staticmethod
1245
1534
  def _assign_layout_temp_ids(body: list[Any], internal_fields: list[JSONObject]) -> None:
1246
1535
  """Assign one temporary-ID sequence to fields and new section wrappers."""
@@ -1319,7 +1608,42 @@ class AppFormCompiler:
1319
1608
  for question in row if isinstance(row, list) else []:
1320
1609
  visit(question)
1321
1610
 
1322
- def _target_body(self, current_body: list[Any], declared_body: list[Any]) -> list[JSONObject]:
1611
+ @staticmethod
1612
+ def _validate_unique_relation_targets(relations: list[JSONObject], fields: list[JSONObject]) -> None:
1613
+ field_names: dict[int, str] = {}
1614
+ for field in fields:
1615
+ field_ref = _positive_int(field.get("que_id"))
1616
+ if field_ref is None:
1617
+ field_ref = _coerce_any_int(field.get("que_temp_id"))
1618
+ if field_ref is not None:
1619
+ field_names[field_ref] = str(field.get("name") or field_ref)
1620
+ by_target: dict[int, JSONObject] = {}
1621
+ for relation in relations:
1622
+ target_ref = _coerce_any_int(relation.get("queId"))
1623
+ if target_ref is None:
1624
+ continue
1625
+ previous = by_target.get(target_ref)
1626
+ if previous is None:
1627
+ by_target[target_ref] = relation
1628
+ continue
1629
+ target_name = field_names.get(target_ref, str(target_ref))
1630
+ producers = []
1631
+ for item in (previous, relation):
1632
+ alias_config = item.get("aliasConfig") if isinstance(item.get("aliasConfig"), dict) else {}
1633
+ producer = str(alias_config.get("queTitle") or "").strip()
1634
+ producers.append(producer or f"relationType={item.get('relationType')}")
1635
+ raise ValueError(
1636
+ f"field '{target_name}' is targeted by multiple default-value relations "
1637
+ f"({producers[0]}, {producers[1]}); each field can receive output from only one relation"
1638
+ )
1639
+
1640
+ def _target_body(
1641
+ self,
1642
+ current_body: list[Any],
1643
+ declared_body: list[Any],
1644
+ *,
1645
+ subfields: bool = False,
1646
+ ) -> list[JSONObject]:
1323
1647
  current_fields = list(_iter_fields(current_body))
1324
1648
  by_id = {_positive_int(field.get("queId")): field for field in current_fields if _positive_int(field.get("queId"))}
1325
1649
  sections_by_id = {
@@ -1337,8 +1661,8 @@ class AppFormCompiler:
1337
1661
  target = _deep_overlay(current or {}, declared)
1338
1662
  if "config" in declared:
1339
1663
  target["config"] = deepcopy(declared["config"])
1340
- target.setdefault("required", False)
1341
- target.setdefault("description", None)
1664
+ target.setdefault("beingRequired", False)
1665
+ target.setdefault("questionDescription", None)
1342
1666
  if target.get("defaultValue") is not None:
1343
1667
  target.setdefault("defaultType", 1)
1344
1668
  if "defaultValue" in declared and declared.get("defaultValue") is None:
@@ -1349,8 +1673,8 @@ class AppFormCompiler:
1349
1673
  target["queId"] = current.get("queId")
1350
1674
  else:
1351
1675
  target.pop("queId", None)
1352
- if target.get("type") in CHOICE_TYPES and "config" not in declared and current is None:
1353
- target["config"] = {"options": ["是", "否"] if target.get("type") == "single_choice" else []}
1676
+ if _field_type(target) in CHOICE_TYPES and "config" not in declared and current is None:
1677
+ target["config"] = {"options": ["是", "否"] if _field_type(target) == "single_choice" else []}
1354
1678
  return target
1355
1679
 
1356
1680
  def target_field(declared: JSONObject, *, nested: bool) -> JSONObject:
@@ -1363,7 +1687,7 @@ class AppFormCompiler:
1363
1687
  comparable.pop("queId", None)
1364
1688
  candidates = []
1365
1689
  for candidate in current_fields:
1366
- if id(candidate) in claimed_fields or candidate.get("type") != declared.get("type"):
1690
+ if id(candidate) in claimed_fields or _field_type(candidate) != _field_type(declared):
1367
1691
  continue
1368
1692
  merged = merge_field(candidate, comparable)
1369
1693
  if _field_semantic(merged) == _field_semantic(candidate):
@@ -1376,16 +1700,17 @@ class AppFormCompiler:
1376
1700
  details={"name": declared.get("name"), "queIds": [item.get("queId") for item in candidates]},
1377
1701
  )
1378
1702
  current = candidates[0] if candidates else None
1379
- if current is not None and current.get("type") != declared.get("type"):
1380
- raise AppFormFailure("FIELD_TYPE_IMMUTABLE", "field type cannot be changed in place", "CONTRACT_PRECHECK", details={"queId": current.get("queId"), "currentType": current.get("type"), "targetType": declared.get("type")})
1703
+ if current is not None and _field_type(current) != _field_type(declared):
1704
+ raise AppFormFailure("FIELD_TYPE_IMMUTABLE", "field type cannot be changed in place", "CONTRACT_PRECHECK", details={"queId": current.get("queId"), "currentType": _field_type(current), "targetType": _field_type(declared)})
1381
1705
  if current is not None:
1382
1706
  claimed_fields.add(id(current))
1383
1707
  target = merge_field(current, declared)
1384
- if target.get("type") == "subtable":
1708
+ if _field_type(target) == "subtable":
1385
1709
  current_subfields = current.get("subfields") if isinstance((current or {}).get("subfields"), list) else []
1386
1710
  target["subfields"] = self._target_body(
1387
1711
  [{"kind": "row", "fields": current_subfields}],
1388
1712
  [{"kind": "row", "fields": target.get("subfields") or []}],
1713
+ subfields=True,
1389
1714
  )[0]["fields"]
1390
1715
  for item in target["subfields"]:
1391
1716
  item.pop("width", None)
@@ -1397,13 +1722,15 @@ class AppFormCompiler:
1397
1722
  for block in declared_body:
1398
1723
  target_block = deepcopy(block)
1399
1724
  if block.get("kind") == "row":
1400
- target_block["fields"] = [target_field(field, nested=False) for field in block.get("fields") or []]
1401
- self._fill_widths(target_block["fields"])
1725
+ target_block["fields"] = [target_field(field, nested=subfields) for field in block.get("fields") or []]
1726
+ if not subfields:
1727
+ self._fill_widths(target_block["fields"])
1402
1728
  else:
1403
1729
  target_block["rows"] = []
1404
1730
  for row in block.get("rows") or []:
1405
- fields = [target_field(field, nested=False) for field in row.get("fields") or []]
1406
- self._fill_widths(fields)
1731
+ fields = [target_field(field, nested=subfields) for field in row.get("fields") or []]
1732
+ if not subfields:
1733
+ self._fill_widths(fields)
1407
1734
  target_block["rows"].append({"fields": fields})
1408
1735
  section_id = str(block.get("sectionId") or "")
1409
1736
  current_section = sections_by_id.get(section_id) if section_id else None
@@ -1443,40 +1770,75 @@ class AppFormCompiler:
1443
1770
  if any(present) and not all(present):
1444
1771
  raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
1445
1772
  if all(present):
1773
+ if any(_positive_int(field.get("width")) not in {25, 50, 75, 100} for field in fields):
1774
+ raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be one of 25, 50, 75, or 100", "CONTRACT_PRECHECK")
1446
1775
  if sum(int(field["width"]) for field in fields) != 100:
1447
1776
  raise AppFormFailure("INVALID_ROW_WIDTH", "field widths in a row must total 100", "CONTRACT_PRECHECK")
1448
1777
  return
1449
- width, remainder = divmod(100, len(fields))
1450
- for index, field in enumerate(fields):
1451
- field["width"] = width + (1 if index < remainder else 0)
1778
+ widths = {
1779
+ 1: [100],
1780
+ 2: [50, 50],
1781
+ 3: [50, 25, 25],
1782
+ 4: [25, 25, 25, 25],
1783
+ }[len(fields)]
1784
+ for field, width in zip(fields, widths, strict=True):
1785
+ field["width"] = width
1452
1786
 
1453
1787
  @staticmethod
1454
- def _validate_target(body: list[JSONObject]) -> None:
1788
+ def _validate_target(
1789
+ spec: JSONObject,
1790
+ *,
1791
+ allow_missing_data_title: bool = False,
1792
+ ) -> tuple[JSONObject | None, JSONObject | None]:
1793
+ body = spec.get("body") or []
1455
1794
  fields = list(_iter_fields(body))
1456
1795
  names: set[str] = set()
1457
- title_count = 0
1458
- cover_count = 0
1459
1796
  for field in fields:
1460
1797
  name = str(field.get("name") or "")
1461
1798
  if name in names:
1462
1799
  raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
1463
1800
  names.add(name)
1464
- title_count += int(bool(field.get("dataTitle")))
1465
- cover_count += int(bool(field.get("dataCover")))
1466
- if field.get("dataCover") and field.get("type") != "attachment":
1467
- raise AppFormFailure("INVALID_DATA_COVER_FIELD", "dataCover must be an attachment field", "CONTRACT_PRECHECK")
1468
- if field.get("type") == "subtable":
1801
+ if _field_type(field) == "subtable":
1469
1802
  sub_names: set[str] = set()
1470
1803
  for subfield in field.get("subfields") or []:
1471
- if subfield.get("type") not in SUBFIELD_TYPES:
1804
+ if _field_type(subfield) not in SUBFIELD_TYPES:
1472
1805
  raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
1473
1806
  if subfield.get("name") in sub_names:
1474
1807
  raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
1475
1808
  sub_names.add(str(subfield.get("name") or ""))
1476
- if fields and title_count != 1:
1477
- raise AppFormFailure("INVALID_DATA_TITLE_FIELD", "non-empty body must contain exactly one dataTitle field", "CONTRACT_PRECHECK")
1478
- if cover_count > 1:
1479
- raise AppFormFailure("INVALID_DATA_COVER_FIELD", "body can contain at most one dataCover field", "CONTRACT_PRECHECK")
1809
+ title_selector = spec.get("dataTitleField")
1810
+ data_title: JSONObject | None = None
1811
+ if fields:
1812
+ if title_selector is None and allow_missing_data_title and "dataTitleField" not in spec:
1813
+ pass
1814
+ elif not isinstance(title_selector, dict):
1815
+ raise AppFormFailure(
1816
+ "INVALID_DATA_TITLE_FIELD",
1817
+ "non-empty body requires a dataTitleField selector",
1818
+ "CONTRACT_PRECHECK",
1819
+ )
1820
+ else:
1821
+ data_title = _resolve_canonical_selector(title_selector, fields, "dataTitleField")
1822
+ elif title_selector is not None:
1823
+ raise AppFormFailure(
1824
+ "INVALID_DATA_TITLE_FIELD",
1825
+ "dataTitleField must be null when body is empty",
1826
+ "CONTRACT_PRECHECK",
1827
+ )
1828
+
1829
+ cover_selector = spec.get("dataCoverField")
1830
+ data_cover: JSONObject | None = None
1831
+ if cover_selector is not None:
1832
+ if not isinstance(cover_selector, dict):
1833
+ raise AppFormFailure("INVALID_DATA_COVER_FIELD", "dataCoverField must be a field selector or null", "CONTRACT_PRECHECK")
1834
+ data_cover = _resolve_canonical_selector(cover_selector, fields, "dataCoverField")
1835
+ if _field_type(data_cover) != "attachment":
1836
+ raise AppFormFailure(
1837
+ "INVALID_DATA_COVER_FIELD",
1838
+ "dataCoverField must select a top-level attachment field",
1839
+ "CONTRACT_PRECHECK",
1840
+ )
1841
+ return data_title, data_cover
1480
1842
 
1481
1843
  def payload(
1482
1844
  self,
@@ -1497,6 +1859,7 @@ class AppFormCompiler:
1497
1859
  relation_context = deepcopy(current_form)
1498
1860
  relation_context["questionRelations"] = relations
1499
1861
  internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
1862
+ self._validate_unique_relation_targets(relations, internal_fields)
1500
1863
  except (KeyError, ValueError) as error:
1501
1864
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
1502
1865
  self._assign_subfield_temp_ids(internal_fields)
@@ -1553,8 +1916,9 @@ class AppFormCompiler:
1553
1916
  rows.append([section])
1554
1917
  payload["formQues"] = rows
1555
1918
  self._validate_unique_temp_ids(rows)
1556
- data_title = next((field for field in target_fields if field.get("dataTitle")), None)
1557
- data_cover = next((field for field in target_fields if field.get("dataCover")), None)
1919
+ data_title = _resolve_canonical_selector(target["spec"]["dataTitleField"], target_fields, "dataTitleField") if target_fields else None
1920
+ data_cover_selector = target["spec"].get("dataCoverField")
1921
+ data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
1558
1922
  payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
1559
1923
  payload["dataCoverQueId"] = self._field_runtime_id(data_cover, internal_fields) if data_cover else None
1560
1924
  payload["questionRelations"].extend(data_relations)
@@ -1569,8 +1933,8 @@ class AppFormCompiler:
1569
1933
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
1570
1934
  binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
1571
1935
  if _positive_int(field.get("queId")) is None and (
1572
- field.get("type") in {"q_linker", "data_relation"}
1573
- or field.get("type") == "code_block" and bool((binding or {}).get("outputs"))
1936
+ _field_type(field) in {"q_linker", "data_relation"}
1937
+ or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
1574
1938
  ):
1575
1939
  return True
1576
1940
  if isinstance(binding, dict):
@@ -1578,7 +1942,7 @@ class AppFormCompiler:
1578
1942
  selectors += [item.get("targetField") for item in binding.get("outputs") or [] if isinstance(item, dict)]
1579
1943
  if any(isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names for selector in selectors):
1580
1944
  return True
1581
- if field.get("type") == "data_relation" and json.dumps(config, ensure_ascii=False, sort_keys=True):
1945
+ if _field_type(field) == "data_relation" and json.dumps(config, ensure_ascii=False, sort_keys=True):
1582
1946
  for selector in _walk_selectors(config):
1583
1947
  if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
1584
1948
  return True
@@ -1593,8 +1957,8 @@ class AppFormCompiler:
1593
1957
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
1594
1958
  binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
1595
1959
  if _positive_int(field.get("queId")) is None and (
1596
- field.get("type") in {"q_linker", "data_relation"}
1597
- or field.get("type") == "code_block" and bool((binding or {}).get("outputs"))
1960
+ _field_type(field) in {"q_linker", "data_relation"}
1961
+ or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
1598
1962
  ):
1599
1963
  pending.append(
1600
1964
  {
@@ -1612,7 +1976,7 @@ class AppFormCompiler:
1612
1976
  selector = item.get("targetField") if isinstance(item, dict) else None
1613
1977
  if isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
1614
1978
  pending.append({"field": field.get("name"), "path": f"outputs/{index}", "selector": deepcopy(selector)})
1615
- if field.get("type") == "data_relation":
1979
+ if _field_type(field) == "data_relation":
1616
1980
  for selector in _walk_selectors(config):
1617
1981
  if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
1618
1982
  pending.append({"field": field.get("name"), "path": "config", "selector": deepcopy(selector)})
@@ -1706,14 +2070,14 @@ class AppFormCompiler:
1706
2070
  temp_id -= 1
1707
2071
 
1708
2072
  def _internal_field(self, field: JSONObject, *, templates: dict[int, JSONObject]) -> JSONObject:
1709
- canonical_type = str(field.get("type"))
2073
+ canonical_type = _field_type(field)
1710
2074
  internal_type = "boolean" if canonical_type == "single_choice" else canonical_type
1711
2075
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
1712
2076
  result: JSONObject = {
1713
2077
  "name": field["name"],
1714
2078
  "type": internal_type,
1715
- "required": bool(field.get("required")),
1716
- "description": field.get("description"),
2079
+ "required": _field_required(field),
2080
+ "description": field.get("questionDescription"),
1717
2081
  "que_id": _positive_int(field.get("queId")),
1718
2082
  "options": deepcopy(config.get("options") or []),
1719
2083
  "subfields": [self._internal_field(item, templates=templates) for item in field.get("subfields") or []],
@@ -1775,7 +2139,7 @@ class AppFormCompiler:
1775
2139
  return result
1776
2140
 
1777
2141
  def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
1778
- field_type = str(field["type"])
2142
+ field_type = _field_type(field)
1779
2143
  que_type = CANONICAL_TO_QUE_TYPE[field_type]
1780
2144
  build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
1781
2145
  built_internal = deepcopy(internal)
@@ -1786,7 +2150,7 @@ class AppFormCompiler:
1786
2150
  generated = _field_to_question(built_internal, temp_id=temp_id)
1787
2151
  except (KeyError, ValueError):
1788
2152
  generated, _ = build_question(
1789
- {"label": field["name"], "type": FieldType.text, "required": bool(field.get("required")), "description": field.get("description"), "config": {}},
2153
+ {"label": field["name"], "type": FieldType.text, "required": _field_required(field), "description": field.get("questionDescription"), "config": {}},
1790
2154
  temp_id,
1791
2155
  )
1792
2156
  template = internal.get("_question_template") if isinstance(internal.get("_question_template"), dict) else None
@@ -1805,8 +2169,8 @@ class AppFormCompiler:
1805
2169
  "queType": que_type,
1806
2170
  "queTitle": field["name"],
1807
2171
  "queWidth": int(field.get("width") or 100),
1808
- "required": bool(field.get("required")),
1809
- "queHint": field.get("description") or "",
2172
+ "required": _field_required(field),
2173
+ "queHint": field.get("questionDescription") or "",
1810
2174
  }
1811
2175
  )
1812
2176
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
@@ -1973,6 +2337,7 @@ class AppFormCompiler:
1973
2337
  ],
1974
2338
  "referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
1975
2339
  "beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
2340
+ "defaultValueFormula": config.get("defaultValueFormula"),
1976
2341
  "defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
1977
2342
  }
1978
2343
  )
@@ -2045,7 +2410,7 @@ class AppFormCompiler:
2045
2410
  available_internal_fields = list(_iter_field_tree(internal_fields))
2046
2411
  result = []
2047
2412
  for field, internal in _iter_field_pairs(fields, internal_fields):
2048
- if field.get("type") != "data_relation":
2413
+ if _field_type(field) != "data_relation":
2049
2414
  continue
2050
2415
  config = field.get("config") or {}
2051
2416
  source = config.get("source") or {}
@@ -2196,7 +2561,17 @@ class AppFormCompiler:
2196
2561
  "judgeType": JUDGE_TYPE_TO_BACKEND[operator],
2197
2562
  }
2198
2563
  if operand.get("kind") == "literal":
2199
- payload.update({"matchType": 1, "judgeValues": deepcopy(operand.get("values") or [])})
2564
+ judge_values, judge_value_details = cls._data_relation_literal_values(
2565
+ source,
2566
+ deepcopy(operand.get("values") or []),
2567
+ )
2568
+ payload.update(
2569
+ {
2570
+ "matchType": 1,
2571
+ "judgeValues": judge_values,
2572
+ "judgeValueDetails": judge_value_details,
2573
+ }
2574
+ )
2200
2575
  elif operand.get("kind") == "current_field":
2201
2576
  target = _resolve_internal_selector(operand.get("field") or {}, current_fields, "operand.field")
2202
2577
  target_id = target.get("que_id") or target.get("que_temp_id")
@@ -2213,6 +2588,53 @@ class AppFormCompiler:
2213
2588
  )
2214
2589
  return payload
2215
2590
 
2591
+ @staticmethod
2592
+ def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
2593
+ field_type = _canonical_backend_field_type(source)
2594
+ options = [item for item in source.get("options") or [] if isinstance(item, dict)]
2595
+ option_by_name = {
2596
+ str(item.get("optValue")): item
2597
+ for item in options
2598
+ if item.get("optValue") is not None
2599
+ }
2600
+ option_by_id = {
2601
+ str(item.get("optId")): item
2602
+ for item in options
2603
+ if _positive_int(item.get("optId")) is not None
2604
+ }
2605
+ judge_values: list[str] = []
2606
+ details: list[JSONObject] = []
2607
+ for value in values:
2608
+ if isinstance(value, bool):
2609
+ text = "true" if value else "false"
2610
+ elif isinstance(value, (str, int, float)):
2611
+ text = str(value)
2612
+ else:
2613
+ raise AppFormFailure(
2614
+ "UNSUPPORTED_FIELD_CONFIG",
2615
+ "literal data_relation values must be strings",
2616
+ "CONTRACT_PRECHECK",
2617
+ details={"field": AppFormCompiler._data_relation_field_name(source), "value": value},
2618
+ )
2619
+ if field_type in CHOICE_TYPES and options:
2620
+ option = option_by_name.get(text) or option_by_id.get(text)
2621
+ if option is None:
2622
+ raise AppFormFailure(
2623
+ "UNSUPPORTED_FIELD_CONFIG",
2624
+ f"literal value {text!r} is not an option of data_relation source field "
2625
+ f"{AppFormCompiler._data_relation_field_name(source)!r}",
2626
+ "CONTRACT_PRECHECK",
2627
+ details={"value": text, "allowedValues": sorted(option_by_name)},
2628
+ )
2629
+ option_id = _positive_int(option.get("optId"))
2630
+ option_name = str(option.get("optValue") or text)
2631
+ judge_values.append(str(option_id) if option_id is not None else option_name)
2632
+ details.append(_without_none({"id": option_id, "value": option_name}))
2633
+ continue
2634
+ judge_values.append(text)
2635
+ details.append({"value": text})
2636
+ return judge_values, details
2637
+
2216
2638
  @staticmethod
2217
2639
  def _resolve_data_relation_source_field(
2218
2640
  selector: JSONObject,
@@ -2238,7 +2660,7 @@ class AppFormCompiler:
2238
2660
  backend_type = _positive_int(field.get("queType"))
2239
2661
  if backend_type is not None:
2240
2662
  return backend_type
2241
- return CANONICAL_TO_QUE_TYPE.get(str(field.get("_app_form_type") or field.get("type") or ""), 2)
2663
+ return CANONICAL_TO_QUE_TYPE.get(str(field.get("_app_form_type") or _field_type(field)), 2)
2242
2664
 
2243
2665
  @staticmethod
2244
2666
  def _data_relation_alias_metadata(
@@ -2270,7 +2692,7 @@ class AppFormCompiler:
2270
2692
  output = matches[0]
2271
2693
  selector = output.get("target_field") or output.get("targetField") or {}
2272
2694
  target = _resolve_internal_selector(selector, internal_fields, "source.alias.targetField")
2273
- origin_type = CANONICAL_TO_QUE_TYPE.get(str(target.get("_app_form_type") or target.get("type") or ""))
2695
+ origin_type = CANONICAL_TO_QUE_TYPE.get(str(target.get("_app_form_type") or _field_type(target)))
2274
2696
  if origin_type is None:
2275
2697
  raise AppFormFailure(
2276
2698
  "UNSUPPORTED_FIELD_CONFIG",
@@ -2324,9 +2746,7 @@ class AppFormCompiler:
2324
2746
 
2325
2747
  def _apply_form_settings(self, payload: JSONObject, *, current_form: JSONObject, target_spec: JSONObject, internal_fields: list[JSONObject]) -> None:
2326
2748
  payload["formTitle"] = target_spec["appName"]
2327
- payload["formDesc"] = target_spec.get("description") or ""
2328
- payload["formTheme"] = target_spec.get("theme")
2329
- payload["formStyle"] = deepcopy(target_spec.get("style"))
2749
+ payload["formDesc"] = target_spec.get("appDescription") or ""
2330
2750
  current_attachments = {
2331
2751
  (str(item.get("fileName") or item.get("name") or ""), str(item.get("fileUrl") or item.get("url") or "")): item
2332
2752
  for item in current_form.get("formAttach") or []
@@ -2414,9 +2834,22 @@ class AppFormReconciler:
2414
2834
  self.normalizer = AppFormNormalizer()
2415
2835
  self.compiler = AppFormCompiler(apps)
2416
2836
 
2417
- def schema(self, *, schema_version: str | None, field_type: str | None) -> JSONObject:
2837
+ def schema(
2838
+ self,
2839
+ *,
2840
+ schema_version: str | None,
2841
+ field_type: str | None,
2842
+ schema_kind: str | None = None,
2843
+ ) -> JSONObject:
2418
2844
  try:
2419
- return {"status": "success", **self.registry.schema(schema_version=schema_version, field_type=field_type)}
2845
+ return {
2846
+ "status": "success",
2847
+ **self.registry.schema(
2848
+ schema_version=schema_version,
2849
+ field_type=field_type,
2850
+ schema_kind=schema_kind,
2851
+ ),
2852
+ }
2420
2853
  except AppFormContractError as error:
2421
2854
  return self._failure(error.error_code, error.message, "SCHEMA", details=error.details)
2422
2855
 
@@ -2438,6 +2871,8 @@ class AppFormReconciler:
2438
2871
  context = self.registry.resolve_schema_version(schema_version)
2439
2872
  normalizer, _compiler = self._runtime_for(context)
2440
2873
  form = self._read_form(profile, app_key, being_draft=being_draft)
2874
+ if not being_draft:
2875
+ form = _normalize_published_scope_omissions(form)
2441
2876
  resource = normalizer.from_backend(context=context, app_key=app_key, form=form)
2442
2877
  context.validate(resource)
2443
2878
  return {"status": "success", "schemaVersion": context.schema_version, "source": "draft" if being_draft else "published", "resource": resource}
@@ -2474,8 +2909,6 @@ class AppFormReconciler:
2474
2909
  "create mode requires packageId and spec.appName",
2475
2910
  details={"mode": "create"},
2476
2911
  )
2477
- if not is_update:
2478
- validate_app_form_create_icon(declaration)
2479
2912
  apply_dto = context.parse_apply(declaration)
2480
2913
  context.assert_apply_dto(apply_dto)
2481
2914
  declaration = apply_dto.resource
@@ -2517,7 +2950,7 @@ class AppFormReconciler:
2517
2950
  raise
2518
2951
  app_key = self._create(profile, declaration)
2519
2952
  created = True
2520
- diagnostics["operation"] = "CREATED"
2953
+ diagnostics["operation"] = "CREATE_IN_PROGRESS"
2521
2954
  try:
2522
2955
  package_attached = self._package_contains(profile, package_id, app_key) if package_id is not None else None
2523
2956
  except (QingflowApiError, RuntimeError) as error:
@@ -2548,9 +2981,13 @@ class AppFormReconciler:
2548
2981
  publish_needed = created
2549
2982
  if not form_changed and not created:
2550
2983
  try:
2551
- published_form = self._read_form(profile, app_key, being_draft=False)
2552
- published = normalizer.from_backend(context=context, app_key=app_key, form=published_form)
2553
- publish_needed = not _canonical_equal(current, published)
2984
+ published = self._published_resource(
2985
+ profile=profile,
2986
+ app_key=app_key,
2987
+ context=context,
2988
+ normalizer=normalizer,
2989
+ )
2990
+ publish_needed = not _published_equal(current, published)
2554
2991
  except (QingflowApiError, RuntimeError, AppFormFailure) as error:
2555
2992
  raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
2556
2993
  diagnostics["diff"] = self._diff(current, target)
@@ -2624,6 +3061,7 @@ class AppFormReconciler:
2624
3061
  self._raise_write_failure(error, diagnostics, first_write=False)
2625
3062
  diagnostics["writeCount"] += 1
2626
3063
  diagnostics["formPhase"] = "RELATION_REBIND"
3064
+ diagnostics["pendingBindings"] = []
2627
3065
 
2628
3066
  if wrote_form:
2629
3067
  draft_form = self._read_form(profile, app_key, being_draft=True)
@@ -2655,9 +3093,13 @@ class AppFormReconciler:
2655
3093
  publish_needed = created or wrote_form
2656
3094
  if not publish_needed:
2657
3095
  try:
2658
- published_form = self._read_form(profile, app_key, being_draft=False)
2659
- published = normalizer.from_backend(context=context, app_key=app_key, form=published_form)
2660
- diagnostics["published"] = _canonical_equal(draft, published)
3096
+ published = self._published_resource(
3097
+ profile=profile,
3098
+ app_key=app_key,
3099
+ context=context,
3100
+ normalizer=normalizer,
3101
+ )
3102
+ diagnostics["published"] = _published_equal(draft, published)
2661
3103
  publish_needed = not diagnostics["published"]
2662
3104
  except (QingflowApiError, RuntimeError, AppFormFailure) as error:
2663
3105
  self._finish_edit(profile, app_key, edit_version_no, diagnostics)
@@ -2685,10 +3127,86 @@ class AppFormReconciler:
2685
3127
  diagnostics["operation"] = "CREATED" if created else "APPLIED" if wrote_form or publish_needed or diagnostics["packageAttachCount"] else "UNCHANGED"
2686
3128
  return self._success(context, draft, diagnostics, warnings)
2687
3129
  except AppFormContractError as error:
2688
- return self._failure(error.error_code, error.message, "VALIDATE", details={**error.details, **({"path": error.path} if error.path else {})}, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3130
+ if created and app_key:
3131
+ return self._rollback_created_after_failure(
3132
+ profile=profile,
3133
+ app_key=app_key,
3134
+ error_code=error.error_code,
3135
+ message=error.message,
3136
+ phase="VALIDATE",
3137
+ details={**error.details, **({"path": error.path} if error.path else {})},
3138
+ diagnostics=diagnostics,
3139
+ warnings=warnings,
3140
+ )
3141
+ diagnostics["operation"] = {
3142
+ "create": "CREATE_FAILED",
3143
+ "update": "UPDATE_FAILED",
3144
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
3145
+ validation_diagnostics = {
3146
+ key: diagnostics[key]
3147
+ for key in ("operation", "applyMode")
3148
+ if diagnostics.get(key) is not None
3149
+ }
3150
+ return self._failure(
3151
+ error.error_code,
3152
+ error.message,
3153
+ "VALIDATE",
3154
+ details={**error.details, **({"path": error.path} if error.path else {})},
3155
+ diagnostics=validation_diagnostics,
3156
+ app_key=app_key,
3157
+ warnings=warnings,
3158
+ )
2689
3159
  except AppFormFailure as error:
3160
+ preserve_created_app = error.error_code == "APPLY_READBACK_MISMATCH"
3161
+ if (
3162
+ created
3163
+ and app_key
3164
+ and not preserve_created_app
3165
+ and error.status != "unknown"
3166
+ and diagnostics.get("writeMayHaveSucceeded") is not True
3167
+ ):
3168
+ return self._rollback_created_after_failure(
3169
+ profile=profile,
3170
+ app_key=app_key,
3171
+ error_code=error.error_code,
3172
+ message=error.message,
3173
+ phase=error.phase,
3174
+ details=error.details,
3175
+ diagnostics=diagnostics,
3176
+ warnings=warnings,
3177
+ )
3178
+ if created:
3179
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3180
+ else:
3181
+ diagnostics["operation"] = {
3182
+ "create": "CREATE_FAILED",
3183
+ "update": "UPDATE_FAILED",
3184
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
2690
3185
  status = "partial_success" if created and error.status == "failed" else error.status
2691
- return self._failure(error.error_code, error.message, error.phase, status=status, details=error.details, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3186
+ result = self._failure(error.error_code, error.message, error.phase, status=status, details=error.details, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3187
+ if error.error_code == "APPLY_READBACK_MISMATCH" and app_key:
3188
+ result["requiresFurtherRepair"] = True
3189
+ result["mustReadCurrentStateBeforeRepair"] = True
3190
+ result["nextApplyMode"] = "update"
3191
+ result["suggested_next_call"] = {
3192
+ "tool_name": "app_form_get",
3193
+ "arguments": {
3194
+ "profile": profile,
3195
+ "app_key": app_key,
3196
+ "being_draft": True,
3197
+ },
3198
+ }
3199
+ if created:
3200
+ result["message"] = (
3201
+ "the application was created and its form was saved, but backend readback still differs from the apply target; "
3202
+ "the application was preserved and was not published"
3203
+ )
3204
+ result["createdAppPreserved"] = True
3205
+ result["recoveryHint"] = (
3206
+ "first read the current draft using the returned appKey; compare it with remainingDiff, then merge the intended changes into that current state, "
3207
+ "keep the appKey in the declaration, validate, and apply again in update mode"
3208
+ )
3209
+ return result
2692
3210
  except (QingflowApiError, RuntimeError) as error:
2693
3211
  uncertain = self._is_uncertain_api_error(error)
2694
3212
  code = (
@@ -2700,6 +3218,24 @@ class AppFormReconciler:
2700
3218
  if uncertain
2701
3219
  else "APP_FORM_APPLY_FAILED"
2702
3220
  )
3221
+ if created and app_key and not uncertain:
3222
+ return self._rollback_created_after_failure(
3223
+ profile=profile,
3224
+ app_key=app_key,
3225
+ error_code=code,
3226
+ message=str(self._error_details(error).get("message") or error),
3227
+ phase="APPLY",
3228
+ details=self._error_details(error),
3229
+ diagnostics=diagnostics,
3230
+ warnings=warnings,
3231
+ )
3232
+ if created:
3233
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3234
+ else:
3235
+ diagnostics["operation"] = {
3236
+ "create": "CREATE_FAILED",
3237
+ "update": "UPDATE_FAILED",
3238
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
2703
3239
  mutation_started = bool(
2704
3240
  created
2705
3241
  or diagnostics["writeAttempts"]
@@ -2709,6 +3245,60 @@ class AppFormReconciler:
2709
3245
  status = "unknown" if uncertain else "partial_success" if mutation_started else "failed"
2710
3246
  return self._api_failure(code, error, "CREATE" if not app_key else "APPLY", status=status, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
2711
3247
 
3248
+ def _rollback_created_after_failure(
3249
+ self,
3250
+ *,
3251
+ profile: str,
3252
+ app_key: str,
3253
+ error_code: str,
3254
+ message: str,
3255
+ phase: str,
3256
+ details: JSONObject | None,
3257
+ diagnostics: JSONObject,
3258
+ warnings: list[JSONObject],
3259
+ ) -> JSONObject:
3260
+ diagnostics["createRollbackAttempted"] = True
3261
+ failure_details = deepcopy(details or {})
3262
+ transport_error = failure_details.pop("transportError", None)
3263
+ if isinstance(transport_error, dict):
3264
+ failure_details["failureCause"] = deepcopy(transport_error)
3265
+ try:
3266
+ self.apps.app_delete(profile=profile, app_key=app_key)
3267
+ except (QingflowApiError, RuntimeError) as rollback_error:
3268
+ rollback_details = self._error_details(rollback_error)
3269
+ uncertain = self._is_uncertain_api_error(rollback_error)
3270
+ diagnostics["createRollbackSucceeded"] = None if uncertain else False
3271
+ diagnostics["operation"] = "CREATE_ROLLBACK_UNKNOWN" if uncertain else "CREATE_ROLLBACK_FAILED"
3272
+ failure_details["rollbackError"] = rollback_details
3273
+ return self._failure(
3274
+ error_code,
3275
+ (
3276
+ "AppForm creation failed, and cleanup of the newly created application "
3277
+ f"{'could not be confirmed' if uncertain else 'also failed'}: {message}"
3278
+ ),
3279
+ phase,
3280
+ status="unknown" if uncertain else "partial_success",
3281
+ details=failure_details,
3282
+ diagnostics=diagnostics,
3283
+ app_key=app_key,
3284
+ warnings=warnings,
3285
+ )
3286
+ diagnostics["createRollbackSucceeded"] = True
3287
+ diagnostics["operation"] = "CREATE_ROLLED_BACK"
3288
+ result = self._failure(
3289
+ error_code,
3290
+ f"AppForm creation failed and the newly created application was deleted: {message}",
3291
+ phase,
3292
+ status="failed",
3293
+ details=failure_details,
3294
+ diagnostics=diagnostics,
3295
+ app_key=app_key,
3296
+ warnings=warnings,
3297
+ )
3298
+ result["recoverable"] = True
3299
+ result["recoveryHint"] = "fix the original failure cause, then submit the create declaration again"
3300
+ return result
3301
+
2712
3302
  def delete(self, *, profile: str, app_key: str) -> JSONObject:
2713
3303
  try:
2714
3304
  result = self.apps.app_delete(profile=profile, app_key=app_key)
@@ -2720,11 +3310,9 @@ class AppFormReconciler:
2720
3310
  spec = declaration["spec"]
2721
3311
  app_name = str(spec["appName"])
2722
3312
  package_id = int(declaration["packageId"])
2723
- icon = str(spec["icon"])
2724
- color = str(spec["color"])
2725
3313
  payload = {
2726
3314
  "appName": app_name,
2727
- "appIcon": encode_workspace_icon(icon=icon, color=color, title=app_name),
3315
+ "appIcon": encode_workspace_icon_with_defaults(icon=None, color=None, title=app_name),
2728
3316
  "auth": default_member_auth(),
2729
3317
  "tagIds": [package_id],
2730
3318
  }
@@ -2912,14 +3500,30 @@ class AppFormReconciler:
2912
3500
  diagnostics: JSONObject,
2913
3501
  ) -> bool | None:
2914
3502
  try:
2915
- published_form = self._read_form(profile, app_key, being_draft=False)
2916
3503
  normalizer, _compiler = self._runtime_for(context)
2917
- published = normalizer.from_backend(context=context, app_key=app_key, form=published_form)
2918
- diagnostics["published"] = _canonical_equal(draft, published)
3504
+ published = self._published_resource(
3505
+ profile=profile,
3506
+ app_key=app_key,
3507
+ context=context,
3508
+ normalizer=normalizer,
3509
+ )
3510
+ diagnostics["published"] = _published_equal(draft, published)
2919
3511
  except (QingflowApiError, RuntimeError, AppFormFailure):
2920
3512
  diagnostics["published"] = None
2921
3513
  return diagnostics["published"]
2922
3514
 
3515
+ def _published_resource(
3516
+ self,
3517
+ *,
3518
+ profile: str,
3519
+ app_key: str,
3520
+ context: AppFormSchemaContext,
3521
+ normalizer: AppFormNormalizer,
3522
+ ) -> JSONObject:
3523
+ published_form = self._read_form(profile, app_key, being_draft=False)
3524
+ comparable_form = _normalize_published_scope_omissions(published_form)
3525
+ return normalizer.from_backend(context=context, app_key=app_key, form=comparable_form)
3526
+
2923
3527
  def _finish_edit(self, profile: str, app_key: str, edit_version_no: int, diagnostics: JSONObject) -> None:
2924
3528
  try:
2925
3529
  self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -2963,9 +3567,13 @@ class AppFormReconciler:
2963
3567
  details={**diagnostics, "transportError": details},
2964
3568
  ) from error
2965
3569
  diagnostics["writeMayHaveSucceeded"] = self._is_uncertain_api_error(error)
3570
+ backend_message = str(details.get("message") or "").strip()
3571
+ message = "form write failed or could not be confirmed"
3572
+ if backend_message:
3573
+ message = f"{message}: {backend_message}"
2966
3574
  raise AppFormFailure(
2967
3575
  "APPLY_RESULT_UNKNOWN" if diagnostics["writeMayHaveSucceeded"] else "RELATION_REBIND_FAILED" if not first_write else "APP_FORM_WRITE_FAILED",
2968
- "form write failed or could not be confirmed",
3576
+ message,
2969
3577
  diagnostics["formPhase"],
2970
3578
  status="unknown" if diagnostics["writeMayHaveSucceeded"] else "partial_success" if not first_write else "failed",
2971
3579
  details={**diagnostics, "transportError": details},
@@ -3351,6 +3959,25 @@ def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject]
3351
3959
  return matches[0]
3352
3960
 
3353
3961
 
3962
+ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
3963
+ que_id = _positive_int(selector.get("queId"))
3964
+ name = str(selector.get("name") or "")
3965
+ matches = [
3966
+ item
3967
+ for item in candidates
3968
+ if (que_id is None or _positive_int(item.get("queId")) == que_id)
3969
+ and (not name or str(item.get("name") or "") == name)
3970
+ ]
3971
+ if len(matches) != 1:
3972
+ raise AppFormFailure(
3973
+ "INVALID_FIELD_SELECTOR",
3974
+ f"{location} must resolve to exactly one top-level field",
3975
+ "CONTRACT_PRECHECK",
3976
+ details={"selector": selector, "matches": len(matches)},
3977
+ )
3978
+ return matches[0]
3979
+
3980
+
3354
3981
  def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
3355
3982
  que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
3356
3983
  name = str(selector.get("name") or "")