@josephyan/qingflow-cli 0.2.0-beta.88 → 0.2.0-beta.90

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@0.2.0-beta.88
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.90
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.88 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.90 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "0.2.0-beta.88",
3
+ "version": "0.2.0-beta.90",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4789,12 +4789,22 @@ class AiBuilderFacade:
4789
4789
  response = _apply_permission_outcomes(response, relation_permission_outcome)
4790
4790
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
4791
4791
 
4792
- payload = _build_form_payload_from_fields(
4793
- title=effective_app_name,
4794
- current_schema=schema_result,
4795
- fields=current_fields,
4796
- layout=layout,
4797
- question_relations=compiled_question_relations,
4792
+ payload = (
4793
+ _build_form_payload_from_fields(
4794
+ title=effective_app_name,
4795
+ current_schema=schema_result,
4796
+ fields=current_fields,
4797
+ layout=layout,
4798
+ question_relations=compiled_question_relations,
4799
+ )
4800
+ if bool(resolved.get("created"))
4801
+ else _build_form_payload_for_edit_fields(
4802
+ title=effective_app_name,
4803
+ current_schema=schema_result,
4804
+ fields=current_fields,
4805
+ layout=layout,
4806
+ question_relations=compiled_question_relations,
4807
+ )
4798
4808
  )
4799
4809
  payload["editVersionNo"] = self._resolve_form_edit_version(
4800
4810
  profile=profile,
@@ -4897,12 +4907,22 @@ class AiBuilderFacade:
4897
4907
  },
4898
4908
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4899
4909
  )
4900
- rebound_payload = _build_form_payload_from_fields(
4901
- title=effective_app_name,
4902
- current_schema=rebound_schema,
4903
- fields=rebound_fields,
4904
- layout=rebound_layout,
4905
- question_relations=compiled_question_relations,
4910
+ rebound_payload = (
4911
+ _build_form_payload_from_fields(
4912
+ title=effective_app_name,
4913
+ current_schema=rebound_schema,
4914
+ fields=rebound_fields,
4915
+ layout=rebound_layout,
4916
+ question_relations=compiled_question_relations,
4917
+ )
4918
+ if bool(resolved.get("created"))
4919
+ else _build_form_payload_for_edit_fields(
4920
+ title=effective_app_name,
4921
+ current_schema=rebound_schema,
4922
+ fields=rebound_fields,
4923
+ layout=rebound_layout,
4924
+ question_relations=compiled_question_relations,
4925
+ )
4906
4926
  )
4907
4927
  rebound_payload["editVersionNo"] = self._resolve_form_edit_version(
4908
4928
  profile=profile,
@@ -9844,8 +9864,8 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9844
9864
  "subfields": [],
9845
9865
  "que_id": que_id,
9846
9866
  "que_type": que_type,
9847
- "default_type": _coerce_positive_int(question.get("queDefaultType")) or 1,
9848
- "default_value": question.get("queDefaultValue"),
9867
+ "default_type": _coerce_positive_int(question.get("queDefaultType")) if "queDefaultType" in question else None,
9868
+ "default_value": question.get("queDefaultValue") if "queDefaultValue" in question else None,
9849
9869
  }
9850
9870
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
9851
9871
  options = question.get("options")
@@ -9893,7 +9913,6 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9893
9913
  field["visible_fields"] = visible_fields
9894
9914
  field["field_name_show"] = bool(reference.get("fieldNameShow", True))
9895
9915
  field["_reference_config_template"] = deepcopy(reference)
9896
- field["_question_template"] = deepcopy(question)
9897
9916
  field["_relation_config_explicit"] = False
9898
9917
  if field_type == FieldType.department:
9899
9918
  department_scope = _normalize_department_scope_from_question(question)
@@ -9961,6 +9980,7 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9961
9980
  continue
9962
9981
  subfields.append(_parse_field(sub_question))
9963
9982
  field["subfields"] = subfields
9983
+ field["_question_template"] = deepcopy(question)
9964
9984
  return field
9965
9985
 
9966
9986
 
@@ -10847,53 +10867,82 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10847
10867
  payload.get("type") == FieldType.relation.value
10848
10868
  or any(key in payload for key in ("target_app_key", "display_field", "visible_fields", "relation_mode"))
10849
10869
  )
10870
+ question_overlay_keys = set(cast(list[str], field.get("_question_overlay_keys") or []))
10871
+ question_rebuild_required = bool(field.get("_question_rebuild_required"))
10850
10872
  if "name" in payload:
10851
10873
  field["name"] = payload["name"]
10874
+ question_overlay_keys.add("name")
10852
10875
  if "type" in payload:
10853
10876
  field["type"] = payload["type"]
10877
+ question_rebuild_required = True
10854
10878
  if "required" in payload:
10855
10879
  field["required"] = payload["required"]
10880
+ question_overlay_keys.add("required")
10856
10881
  if "description" in payload:
10857
10882
  field["description"] = payload["description"]
10883
+ question_overlay_keys.add("description")
10858
10884
  if "options" in payload:
10859
10885
  field["options"] = list(payload["options"])
10886
+ question_rebuild_required = True
10860
10887
  if "target_app_key" in payload:
10861
10888
  field["target_app_key"] = payload["target_app_key"]
10889
+ question_rebuild_required = True
10862
10890
  if "display_field" in payload:
10863
10891
  field["display_field"] = payload["display_field"]
10892
+ question_rebuild_required = True
10864
10893
  if "visible_fields" in payload:
10865
10894
  field["visible_fields"] = list(payload["visible_fields"])
10895
+ question_rebuild_required = True
10866
10896
  if "relation_mode" in payload:
10867
10897
  field["relation_mode"] = payload["relation_mode"]
10898
+ question_rebuild_required = True
10868
10899
  if "department_scope" in payload:
10869
10900
  field["department_scope"] = payload["department_scope"]
10901
+ question_rebuild_required = True
10870
10902
  if "remote_lookup_config" in payload:
10871
10903
  field["remote_lookup_config"] = payload["remote_lookup_config"]
10872
10904
  field["config"] = deepcopy(payload["remote_lookup_config"])
10873
10905
  field["_explicit_remote_lookup_config"] = True
10906
+ question_rebuild_required = True
10874
10907
  if "q_linker_binding" in payload:
10875
10908
  field["q_linker_binding"] = payload["q_linker_binding"]
10876
10909
  if "remote_lookup_config" not in payload:
10877
10910
  field["_explicit_remote_lookup_config"] = False
10911
+ question_rebuild_required = True
10878
10912
  if "code_block_config" in payload:
10879
10913
  field["code_block_config"] = payload["code_block_config"]
10880
10914
  field["config"] = deepcopy(payload["code_block_config"])
10915
+ question_rebuild_required = True
10881
10916
  if "code_block_binding" in payload:
10882
10917
  field["code_block_binding"] = payload["code_block_binding"]
10883
10918
  field["_explicit_code_block_binding"] = True
10919
+ question_rebuild_required = True
10884
10920
  if "auto_trigger" in payload:
10885
10921
  field["auto_trigger"] = payload["auto_trigger"]
10922
+ question_rebuild_required = True
10886
10923
  if "custom_button_text_enabled" in payload:
10887
10924
  field["custom_button_text_enabled"] = payload["custom_button_text_enabled"]
10925
+ question_rebuild_required = True
10888
10926
  if "custom_button_text" in payload:
10889
10927
  field["custom_button_text"] = payload["custom_button_text"]
10928
+ question_rebuild_required = True
10890
10929
  if "subfields" in payload:
10891
10930
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
10931
+ question_rebuild_required = True
10892
10932
  if relation_config_explicit:
10893
10933
  field["_relation_config_explicit"] = True
10934
+ question_rebuild_required = True
10894
10935
  elif payload.get("type") and payload.get("type") != FieldType.relation.value:
10895
10936
  field.pop("_relation_config_explicit", None)
10896
10937
  field.pop("_reference_config_template", None)
10938
+ if question_overlay_keys:
10939
+ field["_question_overlay_keys"] = sorted(question_overlay_keys)
10940
+ else:
10941
+ field.pop("_question_overlay_keys", None)
10942
+ if question_rebuild_required:
10943
+ field["_question_rebuild_required"] = True
10944
+ else:
10945
+ field.pop("_question_rebuild_required", None)
10897
10946
 
10898
10947
 
10899
10948
  def _resolve_field_selector_with_uniqueness(
@@ -12691,6 +12740,42 @@ def _verify_package_attachment(packages: PackageTools, *, profile: str, tag_id:
12691
12740
  return last_result
12692
12741
 
12693
12742
 
12743
+ def _field_question_overlay_keys(field: dict[str, Any]) -> set[str]:
12744
+ raw_value = field.get("_question_overlay_keys")
12745
+ if isinstance(raw_value, set):
12746
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
12747
+ if isinstance(raw_value, list):
12748
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
12749
+ return set()
12750
+
12751
+
12752
+ def _field_needs_question_rebuild(field: dict[str, Any]) -> bool:
12753
+ return not isinstance(field.get("_question_template"), dict) or bool(field.get("_question_rebuild_required"))
12754
+
12755
+
12756
+ def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | None:
12757
+ template = deepcopy(field.get("_question_template"))
12758
+ if not isinstance(template, dict):
12759
+ return None
12760
+ overlay_keys = _field_question_overlay_keys(field)
12761
+ if "name" in overlay_keys:
12762
+ template["queTitle"] = str(field.get("name") or "")
12763
+ if "required" in overlay_keys:
12764
+ template["required"] = bool(field.get("required", False))
12765
+ if "description" in overlay_keys:
12766
+ description = field.get("description")
12767
+ template["queHint"] = "" if description is None else str(description)
12768
+ return template
12769
+
12770
+
12771
+ def _materialize_edit_question(field: dict[str, Any], *, temp_id: int) -> tuple[dict[str, Any], bool]:
12772
+ if not _field_needs_question_rebuild(field):
12773
+ preserved = _materialize_preserved_question(field)
12774
+ if preserved is not None:
12775
+ return preserved, True
12776
+ return _field_to_question(field, temp_id=temp_id), False
12777
+
12778
+
12694
12779
  def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]:
12695
12780
  built_question, _next_temp_id = build_question(
12696
12781
  {
@@ -12746,7 +12831,6 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
12746
12831
  )
12747
12832
  if preserved_reference is not None:
12748
12833
  preserved_reference["referAppKey"] = field.get("target_app_key")
12749
- preserved_reference["_targetEntityId"] = field.get("target_app_key")
12750
12834
  question["referenceConfig"] = preserved_reference
12751
12835
  else:
12752
12836
  reference = deepcopy(question.get("referenceConfig")) if isinstance(question.get("referenceConfig"), dict) else {}
@@ -12914,6 +12998,96 @@ def _build_form_payload_from_fields(
12914
12998
  return payload
12915
12999
 
12916
13000
 
13001
+ def _build_form_payload_for_edit_fields(
13002
+ *,
13003
+ title: str,
13004
+ current_schema: dict[str, Any],
13005
+ fields: list[dict[str, Any]],
13006
+ layout: dict[str, Any],
13007
+ question_relations: list[dict[str, Any]] | None = None,
13008
+ ) -> dict[str, Any]:
13009
+ _, section_templates = _extract_question_templates(current_schema)
13010
+ fields_by_name = {
13011
+ str(field.get("name") or ""): field
13012
+ for field in fields
13013
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
13014
+ }
13015
+ form_rows: list[list[dict[str, Any]]] = []
13016
+ temp_id = -10000
13017
+
13018
+ for row in layout.get("root_rows", []) or []:
13019
+ questions: list[dict[str, Any]] = []
13020
+ row_preserved = True
13021
+ for name in row:
13022
+ field = fields_by_name.get(str(name))
13023
+ if field is None:
13024
+ continue
13025
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
13026
+ questions.append(question)
13027
+ row_preserved = row_preserved and preserved
13028
+ temp_id -= 100
13029
+ if not questions:
13030
+ continue
13031
+ if not row_preserved:
13032
+ _apply_row_widths(questions)
13033
+ form_rows.append(questions)
13034
+
13035
+ for section in layout.get("sections", []) or []:
13036
+ inner_rows: list[list[dict[str, Any]]] = []
13037
+ for row in section.get("rows", []) or []:
13038
+ questions: list[dict[str, Any]] = []
13039
+ row_preserved = True
13040
+ for name in row:
13041
+ field = fields_by_name.get(str(name))
13042
+ if field is None:
13043
+ continue
13044
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
13045
+ questions.append(question)
13046
+ row_preserved = row_preserved and preserved
13047
+ temp_id -= 100
13048
+ if not questions:
13049
+ continue
13050
+ if not row_preserved:
13051
+ _apply_row_widths(questions)
13052
+ inner_rows.append(questions)
13053
+ if not inner_rows:
13054
+ continue
13055
+ template = _select_section_template(section_templates, section)
13056
+ wrapper = deepcopy(template) if isinstance(template, dict) else {
13057
+ "queId": 0,
13058
+ "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
13059
+ "queType": 24,
13060
+ "queWidth": 100,
13061
+ "scanType": 1,
13062
+ "status": 1,
13063
+ "required": False,
13064
+ "queHint": "",
13065
+ "linkedQuestions": {},
13066
+ "logicalShow": True,
13067
+ "queDefaultValue": None,
13068
+ "queDefaultType": 1,
13069
+ "subQueWidth": 2,
13070
+ "beingHide": False,
13071
+ "beingDesensitized": False,
13072
+ }
13073
+ if section.get("title") is not None:
13074
+ wrapper["queTitle"] = section.get("title") or wrapper.get("queTitle") or "未命名分组"
13075
+ parsed_section_id = _coerce_positive_int(section.get("section_id"))
13076
+ if parsed_section_id is not None:
13077
+ wrapper["sectionId"] = parsed_section_id
13078
+ elif template is None and section.get("section_id") is not None:
13079
+ wrapper["sectionId"] = section.get("section_id")
13080
+ wrapper["innerQuestions"] = inner_rows
13081
+ form_rows.append([wrapper])
13082
+
13083
+ payload = deepcopy(current_schema)
13084
+ payload["formTitle"] = title
13085
+ payload["formQues"] = form_rows
13086
+ payload["questionRelations"] = deepcopy(question_relations if question_relations is not None else (current_schema.get("questionRelations") or []))
13087
+ payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
13088
+ return payload
13089
+
13090
+
12917
13091
  def _apply_row_widths(row: list[dict[str, Any]]) -> None:
12918
13092
  if not row:
12919
13093
  return
@@ -180,6 +180,16 @@ class AccessibleViewRoute:
180
180
  view_type: str | None = None
181
181
 
182
182
 
183
+ @dataclass(slots=True)
184
+ class RecordContextRouteProbe:
185
+ route: AccessibleViewRoute
186
+ answer_list: list[JSONObject] | None
187
+ used_list_type: int | None
188
+ readable: bool
189
+ transport_error: bool
190
+ error_payload: JSONObject | None
191
+
192
+
183
193
  @dataclass(slots=True)
184
194
  class WorkflowNodeRef:
185
195
  workflow_node_id: int
@@ -782,55 +792,29 @@ class RecordTools(ToolBase):
782
792
  index=app_index,
783
793
  question_relations=question_relations,
784
794
  )
785
- try:
786
- current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
787
- except QingflowApiError:
788
- return self._record_update_schema_blocked_response(
789
- profile=profile,
790
- ws_id=session_profile.selected_ws_id,
791
- request_route=request_route,
792
- app_key=app_key,
793
- record_id=record_id,
794
- blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
795
- warnings=[
796
- "update schema could not load the current record; context-sensitive lookup requirements cannot be derived safely."
797
- ],
798
- recommended_next_actions=[
799
- "Retry after the record becomes readable in the current workspace/profile context.",
800
- "If the issue persists, verify that the current profile still has read access to this record.",
801
- ],
802
- output_profile=normalized_output_profile,
803
- view_probe_summary=[],
804
- ambiguous_fields=[],
805
- )
806
-
807
795
  candidate_routes = self._candidate_update_views(profile, context, app_key)
796
+ probes = self._probe_candidate_record_contexts(
797
+ context,
798
+ app_key=app_key,
799
+ apply_id=record_id,
800
+ candidate_routes=candidate_routes,
801
+ )
808
802
  probe_summary: list[JSONObject] = []
809
- matched_any = False
803
+ matched_probes: list[RecordContextRouteProbe] = []
810
804
  ordered_field_ids: list[int] = []
811
805
  field_payloads_by_id: dict[int, JSONObject] = {}
812
806
  title_to_field_ids: dict[str, list[int]] = {}
813
807
  ws_id = session_profile.selected_ws_id
814
808
 
815
- for candidate in candidate_routes:
816
- matched_record = self._record_matches_accessible_view(
817
- context,
818
- current_answers,
819
- resolved_view=candidate,
820
- )
821
- candidate_summary: JSONObject = {
822
- "view_id": candidate.view_id,
823
- "name": candidate.name,
824
- "kind": candidate.kind,
825
- "matched_record": matched_record,
826
- "context_complete": True,
827
- "writable_field_titles": [],
828
- }
829
- if not matched_record:
809
+ for probe in probes:
810
+ candidate = probe.route
811
+ candidate_summary = self._record_context_probe_summary_payload(probe)
812
+ candidate_summary["writable_field_titles"] = []
813
+ if not probe.readable:
830
814
  probe_summary.append(candidate_summary)
831
815
  continue
832
816
 
833
- matched_any = True
817
+ matched_probes.append(probe)
834
818
  browse_scope = self._build_browse_write_scope(
835
819
  profile,
836
820
  context,
@@ -874,19 +858,39 @@ class RecordTools(ToolBase):
874
858
  candidate_summary["writable_field_titles"] = candidate_titles
875
859
  probe_summary.append(candidate_summary)
876
860
 
877
- if not matched_any:
861
+ if not matched_probes:
862
+ blockers = (
863
+ ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
864
+ if probes and all(probe.transport_error for probe in probes)
865
+ else ["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"]
866
+ )
867
+ warnings = (
868
+ [
869
+ "update schema could not load the current record from any candidate route; context-sensitive lookup requirements cannot be derived safely."
870
+ ]
871
+ if blockers == ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
872
+ else []
873
+ )
874
+ recommended_next_actions = (
875
+ [
876
+ "Retry after the record becomes readable in the current workspace/profile context.",
877
+ "If the issue persists, verify that the current profile still has read access to this record.",
878
+ ]
879
+ if blockers == ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
880
+ else [
881
+ "Check whether this record is still visible in any accessible view for the current profile.",
882
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
883
+ ]
884
+ )
878
885
  return self._record_update_schema_blocked_response(
879
886
  profile=profile,
880
887
  ws_id=ws_id,
881
888
  request_route=request_route,
882
889
  app_key=app_key,
883
890
  record_id=record_id,
884
- blockers=["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"],
885
- warnings=[],
886
- recommended_next_actions=[
887
- "Check whether this record is still visible in any accessible view for the current profile.",
888
- "Use record_get or record_list to confirm the record still exists in the current workspace.",
889
- ],
891
+ blockers=blockers,
892
+ warnings=warnings,
893
+ recommended_next_actions=recommended_next_actions,
890
894
  output_profile=normalized_output_profile,
891
895
  view_probe_summary=probe_summary,
892
896
  ambiguous_fields=[],
@@ -894,7 +898,10 @@ class RecordTools(ToolBase):
894
898
 
895
899
  ambiguous_field_ids: set[int] = set()
896
900
  ambiguous_fields: list[JSONObject] = []
897
- warnings: list[JSONObject] = []
901
+ warnings: list[JSONObject] = [
902
+ {"code": "RECORD_CONTEXT_ROUTE_FALLBACK", "message": message}
903
+ for message in self._record_context_probe_fallback_warnings(probes)
904
+ ]
898
905
  for title, field_ids in title_to_field_ids.items():
899
906
  unique_ids = list(dict.fromkeys(field_ids))
900
907
  if len(unique_ids) <= 1:
@@ -953,6 +960,7 @@ class RecordTools(ToolBase):
953
960
  }
954
961
  if normalized_output_profile == "verbose":
955
962
  response["view_probe_summary"] = probe_summary
963
+ response["record_context_probe"] = probe_summary
956
964
  response["ambiguous_fields"] = ambiguous_fields
957
965
  return response
958
966
 
@@ -2286,9 +2294,15 @@ class RecordTools(ToolBase):
2286
2294
  def runner(session_profile, context):
2287
2295
  request_route = self._request_route_payload(context)
2288
2296
  def build_once(*, effective_force_refresh: bool) -> JSONObject:
2289
- try:
2290
- current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
2291
- except QingflowApiError:
2297
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
2298
+ probes = self._probe_candidate_record_contexts(
2299
+ context,
2300
+ app_key=app_key,
2301
+ apply_id=record_id,
2302
+ candidate_routes=candidate_routes,
2303
+ )
2304
+ matched_probes = [probe for probe in probes if probe.readable]
2305
+ if not matched_probes and probes and all(probe.transport_error for probe in probes):
2292
2306
  return {
2293
2307
  "profile": profile,
2294
2308
  "ws_id": session_profile.selected_ws_id,
@@ -2299,46 +2313,40 @@ class RecordTools(ToolBase):
2299
2313
  record_id=record_id,
2300
2314
  blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
2301
2315
  warnings=[
2302
- "update preflight could not load the current record; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
2316
+ "update preflight could not load the current record from any candidate route; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
2303
2317
  ],
2304
2318
  recommended_next_actions=[
2305
2319
  "Retry after the record becomes readable in the current workspace/profile context.",
2306
2320
  "Call record_update_schema_get to inspect the overall writable field set for this record after context access is restored.",
2307
2321
  ],
2308
- view_probe_summary=[],
2322
+ view_probe_summary=[
2323
+ self._record_context_probe_summary_payload(probe)
2324
+ for probe in probes
2325
+ ],
2309
2326
  ),
2310
2327
  }
2311
2328
 
2312
- candidate_routes = self._candidate_update_views(profile, context, app_key)
2313
2329
  probe_summary: list[JSONObject] = []
2314
- matched_any = False
2315
2330
  matched_routes: list[AccessibleViewRoute] = []
2331
+ matched_answers_for_union: list[JSONObject] | None = None
2316
2332
  first_blocked_plan: JSONObject | None = None
2317
2333
  first_confirmation_plan: JSONObject | None = None
2318
-
2319
- for candidate in candidate_routes:
2320
- matched_record = self._record_matches_accessible_view(
2321
- context,
2322
- current_answers,
2323
- resolved_view=candidate,
2324
- )
2325
- if not matched_record:
2326
- probe_summary.append(
2327
- {
2328
- "view_id": candidate.view_id,
2329
- "name": candidate.name,
2330
- "kind": candidate.kind,
2331
- "matched_record": False,
2332
- "writable_field_titles": [],
2333
- "missing_field_titles": [],
2334
- "context_complete": True,
2335
- "selected": False,
2336
- }
2337
- )
2334
+ fallback_warning_messages = self._record_context_probe_fallback_warnings(probes)
2335
+
2336
+ for probe in probes:
2337
+ candidate = probe.route
2338
+ candidate_summary = self._record_context_probe_summary_payload(probe)
2339
+ candidate_summary["writable_field_titles"] = []
2340
+ candidate_summary["missing_field_titles"] = []
2341
+ candidate_summary["selected"] = False
2342
+ if not probe.readable:
2343
+ probe_summary.append(candidate_summary)
2338
2344
  continue
2339
2345
 
2340
- matched_any = True
2346
+ current_answers = probe.answer_list or []
2341
2347
  matched_routes.append(candidate)
2348
+ if matched_answers_for_union is None:
2349
+ matched_answers_for_union = current_answers
2342
2350
  browse_scope = self._build_browse_write_scope(
2343
2351
  profile,
2344
2352
  context,
@@ -2394,16 +2402,8 @@ class RecordTools(ToolBase):
2394
2402
  title = _normalize_optional_text(field_payload.get("que_title"))
2395
2403
  if title and title not in missing_field_titles:
2396
2404
  missing_field_titles.append(title)
2397
- candidate_summary: JSONObject = {
2398
- "view_id": candidate.view_id,
2399
- "name": candidate.name,
2400
- "kind": candidate.kind,
2401
- "matched_record": True,
2402
- "writable_field_titles": candidate_titles,
2403
- "missing_field_titles": missing_field_titles,
2404
- "context_complete": True,
2405
- "selected": False,
2406
- }
2405
+ candidate_summary["writable_field_titles"] = candidate_titles
2406
+ candidate_summary["missing_field_titles"] = missing_field_titles
2407
2407
  if plan_data.get("blockers"):
2408
2408
  confirmation_requests = plan_data.get("confirmation_requests")
2409
2409
  if (
@@ -2422,8 +2422,26 @@ class RecordTools(ToolBase):
2422
2422
  view_payload["auto_selected"] = True
2423
2423
  view_payload["selection_source"] = "first_satisfying_accessible_view"
2424
2424
  candidate_summary["selected"] = True
2425
+ validation = plan_data.get("validation")
2426
+ if isinstance(validation, dict):
2427
+ warnings = validation.get("warnings")
2428
+ if not isinstance(warnings, list):
2429
+ warnings = []
2430
+ validation["warnings"] = warnings
2431
+ for message in fallback_warning_messages:
2432
+ if message not in warnings:
2433
+ warnings.append(message)
2425
2434
  first_confirmation_plan = plan_data
2426
2435
  elif first_blocked_plan is None:
2436
+ validation = plan_data.get("validation")
2437
+ if isinstance(validation, dict):
2438
+ warnings = validation.get("warnings")
2439
+ if not isinstance(warnings, list):
2440
+ warnings = []
2441
+ validation["warnings"] = warnings
2442
+ for message in fallback_warning_messages:
2443
+ if message not in warnings:
2444
+ warnings.append(message)
2427
2445
  first_blocked_plan = plan_data
2428
2446
  probe_summary.append(candidate_summary)
2429
2447
  continue
@@ -2439,8 +2457,18 @@ class RecordTools(ToolBase):
2439
2457
  view_payload["auto_selected"] = True
2440
2458
  view_payload["selection_source"] = "first_satisfying_accessible_view"
2441
2459
  candidate_summary["selected"] = True
2460
+ validation = plan_data.get("validation")
2461
+ if isinstance(validation, dict):
2462
+ warnings = validation.get("warnings")
2463
+ if not isinstance(warnings, list):
2464
+ warnings = []
2465
+ validation["warnings"] = warnings
2466
+ for message in fallback_warning_messages:
2467
+ if message not in warnings:
2468
+ warnings.append(message)
2442
2469
  probe_summary.append(candidate_summary)
2443
2470
  plan_data["view_probe_summary"] = probe_summary
2471
+ plan_data["record_context_probe"] = probe_summary
2444
2472
  return {
2445
2473
  "profile": profile,
2446
2474
  "ws_id": session_profile.selected_ws_id,
@@ -2449,7 +2477,7 @@ class RecordTools(ToolBase):
2449
2477
  "data": plan_data,
2450
2478
  }
2451
2479
 
2452
- if not matched_any:
2480
+ if not matched_probes:
2453
2481
  blocked_data = self._build_auto_view_blocked_preflight_data(
2454
2482
  app_key=app_key,
2455
2483
  record_id=record_id,
@@ -2471,6 +2499,7 @@ class RecordTools(ToolBase):
2471
2499
 
2472
2500
  if first_confirmation_plan is not None:
2473
2501
  first_confirmation_plan["view_probe_summary"] = probe_summary
2502
+ first_confirmation_plan["record_context_probe"] = probe_summary
2474
2503
  return {
2475
2504
  "profile": profile,
2476
2505
  "ws_id": session_profile.selected_ws_id,
@@ -2485,12 +2514,22 @@ class RecordTools(ToolBase):
2485
2514
  app_key=app_key,
2486
2515
  record_id=record_id,
2487
2516
  fields=fields,
2488
- current_answers=current_answers,
2517
+ current_answers=matched_answers_for_union or [],
2489
2518
  matched_routes=matched_routes,
2490
2519
  force_refresh_form=effective_force_refresh,
2491
2520
  )
2492
2521
  if union_plan is not None:
2522
+ validation = union_plan.get("validation")
2523
+ if isinstance(validation, dict):
2524
+ warnings = validation.get("warnings")
2525
+ if not isinstance(warnings, list):
2526
+ warnings = []
2527
+ validation["warnings"] = warnings
2528
+ for message in fallback_warning_messages:
2529
+ if message not in warnings:
2530
+ warnings.append(message)
2493
2531
  union_plan["view_probe_summary"] = probe_summary
2532
+ union_plan["record_context_probe"] = probe_summary
2494
2533
  return {
2495
2534
  "profile": profile,
2496
2535
  "ws_id": session_profile.selected_ws_id,
@@ -2785,6 +2824,161 @@ class RecordTools(ToolBase):
2785
2824
  )
2786
2825
  return candidates
2787
2826
 
2827
+ def _route_view_key(self, resolved_view: AccessibleViewRoute) -> str | None:
2828
+ if resolved_view.view_selection is not None:
2829
+ view_key = _normalize_optional_text(resolved_view.view_selection.view_key)
2830
+ if view_key:
2831
+ return view_key
2832
+ if resolved_view.kind == "custom" and resolved_view.view_id.startswith("custom:"):
2833
+ view_key = resolved_view.view_id.split(":", 1)[1].strip()
2834
+ return view_key or None
2835
+ return None
2836
+
2837
+ def _record_context_route_error_payload(self, error: QingflowApiError) -> JSONObject:
2838
+ payload: JSONObject = {"message": error.message}
2839
+ if error.category:
2840
+ payload["category"] = error.category
2841
+ if error.backend_code is not None:
2842
+ payload["backend_code"] = error.backend_code
2843
+ if error.http_status is not None:
2844
+ payload["http_status"] = error.http_status
2845
+ return payload
2846
+
2847
+ def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2848
+ if error.backend_code in {40002, 40027, 40038, 404}:
2849
+ return True
2850
+ if error.http_status == 404:
2851
+ return True
2852
+ return False
2853
+
2854
+ def _load_record_answers_for_accessible_route(
2855
+ self,
2856
+ context, # type: ignore[no-untyped-def]
2857
+ *,
2858
+ app_key: str,
2859
+ apply_id: int,
2860
+ resolved_view: AccessibleViewRoute,
2861
+ ) -> tuple[list[JSONObject], int | None]:
2862
+ if resolved_view.kind == "custom":
2863
+ view_key = self._route_view_key(resolved_view)
2864
+ if not view_key:
2865
+ raise_tool_error(
2866
+ QingflowApiError.config_error(
2867
+ f"cannot resolve custom view route for '{resolved_view.view_id}'"
2868
+ )
2869
+ )
2870
+ record = self.backend.request(
2871
+ "GET",
2872
+ context,
2873
+ f"/view/{view_key}/apply/{apply_id}",
2874
+ )
2875
+ used_list_type = None
2876
+ else:
2877
+ used_list_type = resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE
2878
+ record = self.backend.request(
2879
+ "GET",
2880
+ context,
2881
+ f"/app/{app_key}/apply/{apply_id}",
2882
+ params={"role": 1, "listType": used_list_type},
2883
+ )
2884
+ answers = record.get("answers") if isinstance(record, dict) else None
2885
+ normalized_answers = [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
2886
+ return normalized_answers, used_list_type
2887
+
2888
+ def _probe_record_context_route(
2889
+ self,
2890
+ context, # type: ignore[no-untyped-def]
2891
+ *,
2892
+ app_key: str,
2893
+ apply_id: int,
2894
+ resolved_view: AccessibleViewRoute,
2895
+ ) -> RecordContextRouteProbe:
2896
+ try:
2897
+ answer_list, used_list_type = self._load_record_answers_for_accessible_route(
2898
+ context,
2899
+ app_key=app_key,
2900
+ apply_id=apply_id,
2901
+ resolved_view=resolved_view,
2902
+ )
2903
+ return RecordContextRouteProbe(
2904
+ route=resolved_view,
2905
+ answer_list=answer_list,
2906
+ used_list_type=used_list_type,
2907
+ readable=True,
2908
+ transport_error=False,
2909
+ error_payload=None,
2910
+ )
2911
+ except QingflowApiError as exc:
2912
+ return RecordContextRouteProbe(
2913
+ route=resolved_view,
2914
+ answer_list=None,
2915
+ used_list_type=resolved_view.list_type if resolved_view.kind == "system" else None,
2916
+ readable=False,
2917
+ transport_error=not self._is_record_context_route_miss(exc),
2918
+ error_payload=self._record_context_route_error_payload(exc),
2919
+ )
2920
+
2921
+ def _probe_candidate_record_contexts(
2922
+ self,
2923
+ context, # type: ignore[no-untyped-def]
2924
+ *,
2925
+ app_key: str,
2926
+ apply_id: int,
2927
+ candidate_routes: list[AccessibleViewRoute],
2928
+ ) -> list[RecordContextRouteProbe]:
2929
+ return [
2930
+ self._probe_record_context_route(
2931
+ context,
2932
+ app_key=app_key,
2933
+ apply_id=apply_id,
2934
+ resolved_view=candidate,
2935
+ )
2936
+ for candidate in candidate_routes
2937
+ ]
2938
+
2939
+ def _record_context_probe_summary_payload(self, probe: RecordContextRouteProbe) -> JSONObject:
2940
+ payload: JSONObject = {
2941
+ "view_id": probe.route.view_id,
2942
+ "name": probe.route.name,
2943
+ "kind": probe.route.kind,
2944
+ "matched_record": probe.readable,
2945
+ "readable": probe.readable,
2946
+ "context_complete": probe.readable,
2947
+ "used_list_type": probe.used_list_type,
2948
+ }
2949
+ if probe.error_payload is not None:
2950
+ payload["error"] = probe.error_payload
2951
+ payload["transport_error"] = probe.transport_error
2952
+ return payload
2953
+
2954
+ def _record_context_probe_fallback_warnings(
2955
+ self,
2956
+ probes: list[RecordContextRouteProbe],
2957
+ ) -> list[str]:
2958
+ matched_probes = [probe for probe in probes if probe.readable]
2959
+ if not matched_probes:
2960
+ return []
2961
+ if any(
2962
+ probe.route.kind == "system" and probe.used_list_type == DEFAULT_RECORD_LIST_TYPE
2963
+ for probe in matched_probes
2964
+ ):
2965
+ return []
2966
+ first_probe = matched_probes[0]
2967
+ if first_probe.route.kind == "custom":
2968
+ return [
2969
+ "current record context was not accessible via listType="
2970
+ f"{DEFAULT_RECORD_LIST_TYPE}; preflight resolved it via custom view "
2971
+ f"'{first_probe.route.name}'."
2972
+ ]
2973
+ used_list_type = first_probe.used_list_type
2974
+ if used_list_type is None:
2975
+ return []
2976
+ return [
2977
+ "current record context was not accessible via listType="
2978
+ f"{DEFAULT_RECORD_LIST_TYPE}; preflight resolved it via listType={used_list_type} "
2979
+ f"({get_record_list_type_label(used_list_type)})."
2980
+ ]
2981
+
2788
2982
  def _record_matches_accessible_view(
2789
2983
  self,
2790
2984
  context, # type: ignore[no-untyped-def]
@@ -5276,7 +5470,19 @@ class RecordTools(ToolBase):
5276
5470
  existing_answers_loaded = True
5277
5471
  else:
5278
5472
  try:
5279
- existing_answers_for_update = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
5473
+ if resolved_view is not None:
5474
+ existing_answers_for_update, _used_list_type = self._load_record_answers_for_accessible_route(
5475
+ context,
5476
+ app_key=app_key,
5477
+ apply_id=apply_id,
5478
+ resolved_view=resolved_view,
5479
+ )
5480
+ else:
5481
+ existing_answers_for_update = self._load_record_answers_for_preflight(
5482
+ context,
5483
+ app_key=app_key,
5484
+ apply_id=apply_id,
5485
+ )
5280
5486
  existing_answers_loaded = True
5281
5487
  except QingflowApiError:
5282
5488
  validation_warnings.append(