@qingflow-tech/qingflow-cli 1.1.43-beta.2 → 1.1.43-beta.4

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 (62) hide show
  1. package/README.md +11 -5
  2. package/entry_point.py +1 -1
  3. package/npm/bin/qingflow.mjs +1 -2
  4. package/npm/lib/runtime.mjs +3 -7
  5. package/package.json +1 -2
  6. package/pyproject.toml +1 -3
  7. package/skills/qingflow-app-form/SKILL.md +23 -11
  8. package/skills/qingflow-cli/SKILL.md +4 -2
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +1 -1
  11. package/skills/qingflow-cli/reference/builder/10-build-single-app.md +3 -3
  12. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +1 -1
  13. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +25 -24
  14. package/skills/qingflow-cli/reference/builder/40-layout.md +5 -5
  15. package/skills/qingflow-cli/reference/builder/README.md +3 -3
  16. package/skills/qingflow-cli/reference/builder/code-integrations/README.md +1 -1
  17. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +7 -7
  18. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  19. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  20. package/skills/qingflow-common/SKILL.md +47 -0
  21. package/skills/qingflow-common/agents/openai.yaml +4 -0
  22. package/src/qingflow_mcp/__main__.py +2 -3
  23. package/src/qingflow_mcp/app_form_compat.py +11 -27
  24. package/src/qingflow_mcp/app_form_validator.py +201 -35
  25. package/src/qingflow_mcp/builder_facade/app_form.py +762 -148
  26. package/src/qingflow_mcp/builder_facade/app_form_schema.py +114 -3
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +310 -245
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +85 -32
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +85 -32
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
  45. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
  46. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +212 -117
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
  50. package/src/qingflow_mcp/builder_facade/service.py +3 -2
  51. package/src/qingflow_mcp/response_trim.py +2 -0
  52. package/src/qingflow_mcp/server.py +24 -3
  53. package/src/qingflow_mcp/server_app_builder.py +2 -2
  54. package/src/qingflow_mcp/tools/record_tools.py +3 -3
  55. package/src/qingflow_mcp/version.py +2 -4
  56. package/docs/local-agent-install.md +0 -370
  57. package/skills/qingflow-mcp-setup/SKILL.md +0 -115
  58. package/skills/qingflow-mcp-setup/agents/openai.yaml +0 -4
  59. package/skills/qingflow-mcp-setup/references/claude-desktop.md +0 -34
  60. package/skills/qingflow-mcp-setup/references/environments.md +0 -62
  61. package/skills/qingflow-mcp-setup/references/generic-stdio.md +0 -32
  62. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +0 -38
@@ -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 []
@@ -2438,6 +2858,8 @@ class AppFormReconciler:
2438
2858
  context = self.registry.resolve_schema_version(schema_version)
2439
2859
  normalizer, _compiler = self._runtime_for(context)
2440
2860
  form = self._read_form(profile, app_key, being_draft=being_draft)
2861
+ if not being_draft:
2862
+ form = _normalize_published_scope_omissions(form)
2441
2863
  resource = normalizer.from_backend(context=context, app_key=app_key, form=form)
2442
2864
  context.validate(resource)
2443
2865
  return {"status": "success", "schemaVersion": context.schema_version, "source": "draft" if being_draft else "published", "resource": resource}
@@ -2474,8 +2896,6 @@ class AppFormReconciler:
2474
2896
  "create mode requires packageId and spec.appName",
2475
2897
  details={"mode": "create"},
2476
2898
  )
2477
- if not is_update:
2478
- validate_app_form_create_icon(declaration)
2479
2899
  apply_dto = context.parse_apply(declaration)
2480
2900
  context.assert_apply_dto(apply_dto)
2481
2901
  declaration = apply_dto.resource
@@ -2517,7 +2937,7 @@ class AppFormReconciler:
2517
2937
  raise
2518
2938
  app_key = self._create(profile, declaration)
2519
2939
  created = True
2520
- diagnostics["operation"] = "CREATED"
2940
+ diagnostics["operation"] = "CREATE_IN_PROGRESS"
2521
2941
  try:
2522
2942
  package_attached = self._package_contains(profile, package_id, app_key) if package_id is not None else None
2523
2943
  except (QingflowApiError, RuntimeError) as error:
@@ -2548,9 +2968,13 @@ class AppFormReconciler:
2548
2968
  publish_needed = created
2549
2969
  if not form_changed and not created:
2550
2970
  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)
2971
+ published = self._published_resource(
2972
+ profile=profile,
2973
+ app_key=app_key,
2974
+ context=context,
2975
+ normalizer=normalizer,
2976
+ )
2977
+ publish_needed = not _published_equal(current, published)
2554
2978
  except (QingflowApiError, RuntimeError, AppFormFailure) as error:
2555
2979
  raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
2556
2980
  diagnostics["diff"] = self._diff(current, target)
@@ -2624,6 +3048,7 @@ class AppFormReconciler:
2624
3048
  self._raise_write_failure(error, diagnostics, first_write=False)
2625
3049
  diagnostics["writeCount"] += 1
2626
3050
  diagnostics["formPhase"] = "RELATION_REBIND"
3051
+ diagnostics["pendingBindings"] = []
2627
3052
 
2628
3053
  if wrote_form:
2629
3054
  draft_form = self._read_form(profile, app_key, being_draft=True)
@@ -2655,9 +3080,13 @@ class AppFormReconciler:
2655
3080
  publish_needed = created or wrote_form
2656
3081
  if not publish_needed:
2657
3082
  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)
3083
+ published = self._published_resource(
3084
+ profile=profile,
3085
+ app_key=app_key,
3086
+ context=context,
3087
+ normalizer=normalizer,
3088
+ )
3089
+ diagnostics["published"] = _published_equal(draft, published)
2661
3090
  publish_needed = not diagnostics["published"]
2662
3091
  except (QingflowApiError, RuntimeError, AppFormFailure) as error:
2663
3092
  self._finish_edit(profile, app_key, edit_version_no, diagnostics)
@@ -2685,10 +3114,86 @@ class AppFormReconciler:
2685
3114
  diagnostics["operation"] = "CREATED" if created else "APPLIED" if wrote_form or publish_needed or diagnostics["packageAttachCount"] else "UNCHANGED"
2686
3115
  return self._success(context, draft, diagnostics, warnings)
2687
3116
  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)
3117
+ if created and app_key:
3118
+ return self._rollback_created_after_failure(
3119
+ profile=profile,
3120
+ app_key=app_key,
3121
+ error_code=error.error_code,
3122
+ message=error.message,
3123
+ phase="VALIDATE",
3124
+ details={**error.details, **({"path": error.path} if error.path else {})},
3125
+ diagnostics=diagnostics,
3126
+ warnings=warnings,
3127
+ )
3128
+ diagnostics["operation"] = {
3129
+ "create": "CREATE_FAILED",
3130
+ "update": "UPDATE_FAILED",
3131
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
3132
+ validation_diagnostics = {
3133
+ key: diagnostics[key]
3134
+ for key in ("operation", "applyMode")
3135
+ if diagnostics.get(key) is not None
3136
+ }
3137
+ return self._failure(
3138
+ error.error_code,
3139
+ error.message,
3140
+ "VALIDATE",
3141
+ details={**error.details, **({"path": error.path} if error.path else {})},
3142
+ diagnostics=validation_diagnostics,
3143
+ app_key=app_key,
3144
+ warnings=warnings,
3145
+ )
2689
3146
  except AppFormFailure as error:
3147
+ preserve_created_app = error.error_code == "APPLY_READBACK_MISMATCH"
3148
+ if (
3149
+ created
3150
+ and app_key
3151
+ and not preserve_created_app
3152
+ and error.status != "unknown"
3153
+ and diagnostics.get("writeMayHaveSucceeded") is not True
3154
+ ):
3155
+ return self._rollback_created_after_failure(
3156
+ profile=profile,
3157
+ app_key=app_key,
3158
+ error_code=error.error_code,
3159
+ message=error.message,
3160
+ phase=error.phase,
3161
+ details=error.details,
3162
+ diagnostics=diagnostics,
3163
+ warnings=warnings,
3164
+ )
3165
+ if created:
3166
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3167
+ else:
3168
+ diagnostics["operation"] = {
3169
+ "create": "CREATE_FAILED",
3170
+ "update": "UPDATE_FAILED",
3171
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
2690
3172
  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)
3173
+ result = self._failure(error.error_code, error.message, error.phase, status=status, details=error.details, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3174
+ if error.error_code == "APPLY_READBACK_MISMATCH" and app_key:
3175
+ result["requiresFurtherRepair"] = True
3176
+ result["mustReadCurrentStateBeforeRepair"] = True
3177
+ result["nextApplyMode"] = "update"
3178
+ result["suggested_next_call"] = {
3179
+ "tool_name": "app_form_get",
3180
+ "arguments": {
3181
+ "profile": profile,
3182
+ "app_key": app_key,
3183
+ "being_draft": True,
3184
+ },
3185
+ }
3186
+ if created:
3187
+ result["message"] = (
3188
+ "the application was created and its form was saved, but backend readback still differs from the apply target; "
3189
+ "the application was preserved and was not published"
3190
+ )
3191
+ result["createdAppPreserved"] = True
3192
+ result["recoveryHint"] = (
3193
+ "first read the current draft using the returned appKey; compare it with remainingDiff, then merge the intended changes into that current state, "
3194
+ "keep the appKey in the declaration, validate, and apply again in update mode"
3195
+ )
3196
+ return result
2692
3197
  except (QingflowApiError, RuntimeError) as error:
2693
3198
  uncertain = self._is_uncertain_api_error(error)
2694
3199
  code = (
@@ -2700,6 +3205,24 @@ class AppFormReconciler:
2700
3205
  if uncertain
2701
3206
  else "APP_FORM_APPLY_FAILED"
2702
3207
  )
3208
+ if created and app_key and not uncertain:
3209
+ return self._rollback_created_after_failure(
3210
+ profile=profile,
3211
+ app_key=app_key,
3212
+ error_code=code,
3213
+ message=str(self._error_details(error).get("message") or error),
3214
+ phase="APPLY",
3215
+ details=self._error_details(error),
3216
+ diagnostics=diagnostics,
3217
+ warnings=warnings,
3218
+ )
3219
+ if created:
3220
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3221
+ else:
3222
+ diagnostics["operation"] = {
3223
+ "create": "CREATE_FAILED",
3224
+ "update": "UPDATE_FAILED",
3225
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
2703
3226
  mutation_started = bool(
2704
3227
  created
2705
3228
  or diagnostics["writeAttempts"]
@@ -2709,6 +3232,60 @@ class AppFormReconciler:
2709
3232
  status = "unknown" if uncertain else "partial_success" if mutation_started else "failed"
2710
3233
  return self._api_failure(code, error, "CREATE" if not app_key else "APPLY", status=status, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
2711
3234
 
3235
+ def _rollback_created_after_failure(
3236
+ self,
3237
+ *,
3238
+ profile: str,
3239
+ app_key: str,
3240
+ error_code: str,
3241
+ message: str,
3242
+ phase: str,
3243
+ details: JSONObject | None,
3244
+ diagnostics: JSONObject,
3245
+ warnings: list[JSONObject],
3246
+ ) -> JSONObject:
3247
+ diagnostics["createRollbackAttempted"] = True
3248
+ failure_details = deepcopy(details or {})
3249
+ transport_error = failure_details.pop("transportError", None)
3250
+ if isinstance(transport_error, dict):
3251
+ failure_details["failureCause"] = deepcopy(transport_error)
3252
+ try:
3253
+ self.apps.app_delete(profile=profile, app_key=app_key)
3254
+ except (QingflowApiError, RuntimeError) as rollback_error:
3255
+ rollback_details = self._error_details(rollback_error)
3256
+ uncertain = self._is_uncertain_api_error(rollback_error)
3257
+ diagnostics["createRollbackSucceeded"] = None if uncertain else False
3258
+ diagnostics["operation"] = "CREATE_ROLLBACK_UNKNOWN" if uncertain else "CREATE_ROLLBACK_FAILED"
3259
+ failure_details["rollbackError"] = rollback_details
3260
+ return self._failure(
3261
+ error_code,
3262
+ (
3263
+ "AppForm creation failed, and cleanup of the newly created application "
3264
+ f"{'could not be confirmed' if uncertain else 'also failed'}: {message}"
3265
+ ),
3266
+ phase,
3267
+ status="unknown" if uncertain else "partial_success",
3268
+ details=failure_details,
3269
+ diagnostics=diagnostics,
3270
+ app_key=app_key,
3271
+ warnings=warnings,
3272
+ )
3273
+ diagnostics["createRollbackSucceeded"] = True
3274
+ diagnostics["operation"] = "CREATE_ROLLED_BACK"
3275
+ result = self._failure(
3276
+ error_code,
3277
+ f"AppForm creation failed and the newly created application was deleted: {message}",
3278
+ phase,
3279
+ status="failed",
3280
+ details=failure_details,
3281
+ diagnostics=diagnostics,
3282
+ app_key=app_key,
3283
+ warnings=warnings,
3284
+ )
3285
+ result["recoverable"] = True
3286
+ result["recoveryHint"] = "fix the original failure cause, then submit the create declaration again"
3287
+ return result
3288
+
2712
3289
  def delete(self, *, profile: str, app_key: str) -> JSONObject:
2713
3290
  try:
2714
3291
  result = self.apps.app_delete(profile=profile, app_key=app_key)
@@ -2720,11 +3297,9 @@ class AppFormReconciler:
2720
3297
  spec = declaration["spec"]
2721
3298
  app_name = str(spec["appName"])
2722
3299
  package_id = int(declaration["packageId"])
2723
- icon = str(spec["icon"])
2724
- color = str(spec["color"])
2725
3300
  payload = {
2726
3301
  "appName": app_name,
2727
- "appIcon": encode_workspace_icon(icon=icon, color=color, title=app_name),
3302
+ "appIcon": encode_workspace_icon_with_defaults(icon=None, color=None, title=app_name),
2728
3303
  "auth": default_member_auth(),
2729
3304
  "tagIds": [package_id],
2730
3305
  }
@@ -2912,14 +3487,30 @@ class AppFormReconciler:
2912
3487
  diagnostics: JSONObject,
2913
3488
  ) -> bool | None:
2914
3489
  try:
2915
- published_form = self._read_form(profile, app_key, being_draft=False)
2916
3490
  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)
3491
+ published = self._published_resource(
3492
+ profile=profile,
3493
+ app_key=app_key,
3494
+ context=context,
3495
+ normalizer=normalizer,
3496
+ )
3497
+ diagnostics["published"] = _published_equal(draft, published)
2919
3498
  except (QingflowApiError, RuntimeError, AppFormFailure):
2920
3499
  diagnostics["published"] = None
2921
3500
  return diagnostics["published"]
2922
3501
 
3502
+ def _published_resource(
3503
+ self,
3504
+ *,
3505
+ profile: str,
3506
+ app_key: str,
3507
+ context: AppFormSchemaContext,
3508
+ normalizer: AppFormNormalizer,
3509
+ ) -> JSONObject:
3510
+ published_form = self._read_form(profile, app_key, being_draft=False)
3511
+ comparable_form = _normalize_published_scope_omissions(published_form)
3512
+ return normalizer.from_backend(context=context, app_key=app_key, form=comparable_form)
3513
+
2923
3514
  def _finish_edit(self, profile: str, app_key: str, edit_version_no: int, diagnostics: JSONObject) -> None:
2924
3515
  try:
2925
3516
  self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -2963,9 +3554,13 @@ class AppFormReconciler:
2963
3554
  details={**diagnostics, "transportError": details},
2964
3555
  ) from error
2965
3556
  diagnostics["writeMayHaveSucceeded"] = self._is_uncertain_api_error(error)
3557
+ backend_message = str(details.get("message") or "").strip()
3558
+ message = "form write failed or could not be confirmed"
3559
+ if backend_message:
3560
+ message = f"{message}: {backend_message}"
2966
3561
  raise AppFormFailure(
2967
3562
  "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",
3563
+ message,
2969
3564
  diagnostics["formPhase"],
2970
3565
  status="unknown" if diagnostics["writeMayHaveSucceeded"] else "partial_success" if not first_write else "failed",
2971
3566
  details={**diagnostics, "transportError": details},
@@ -3351,6 +3946,25 @@ def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject]
3351
3946
  return matches[0]
3352
3947
 
3353
3948
 
3949
+ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
3950
+ que_id = _positive_int(selector.get("queId"))
3951
+ name = str(selector.get("name") or "")
3952
+ matches = [
3953
+ item
3954
+ for item in candidates
3955
+ if (que_id is None or _positive_int(item.get("queId")) == que_id)
3956
+ and (not name or str(item.get("name") or "") == name)
3957
+ ]
3958
+ if len(matches) != 1:
3959
+ raise AppFormFailure(
3960
+ "INVALID_FIELD_SELECTOR",
3961
+ f"{location} must resolve to exactly one top-level field",
3962
+ "CONTRACT_PRECHECK",
3963
+ details={"selector": selector, "matches": len(matches)},
3964
+ )
3965
+ return matches[0]
3966
+
3967
+
3354
3968
  def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
3355
3969
  que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
3356
3970
  name = str(selector.get("name") or "")