@qingflow-tech/qingflow-app-user-mcp 1.0.43 → 1.1.0

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.
@@ -33,12 +33,11 @@ from ..tools.qingbi_report_tools import QingbiReportTools
33
33
  from ..tools.role_tools import RoleTools
34
34
  from ..tools.solution_tools import SolutionTools
35
35
  from ..tools.view_tools import ViewTools
36
- from ..tools.workflow_tools import WorkflowTools
36
+ from . import workflow_spec as workflow_spec_api
37
37
  from .button_style_catalog import button_style_catalog_payload
38
38
  from .models import (
39
39
  AppChartsReadResponse,
40
40
  AppFieldsReadResponse,
41
- AppFlowReadResponse,
42
41
  ChartGetResponse,
43
42
  AppLayoutReadResponse,
44
43
  AppReadSummaryResponse,
@@ -64,8 +63,6 @@ from .models import (
64
63
  FieldRemovePatch,
65
64
  FieldSelector,
66
65
  FieldUpdatePatch,
67
- FlowPlanRequest,
68
- FlowAssigneePatch,
69
66
  LayoutApplyMode,
70
67
  LayoutPlanRequest,
71
68
  LayoutSectionPatch,
@@ -96,11 +93,10 @@ from .models import (
96
93
  ViewGetResponse,
97
94
  ViewsPlanRequest,
98
95
  ViewsPreset,
99
- FlowPreset,
100
- FlowNodePermissionsPatch,
101
96
  )
102
97
 
103
98
  BUILDER_PORTAL_LIST_DETAIL_VERIFY_LIMIT = 50
99
+ CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE = 8
104
100
 
105
101
 
106
102
  QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
@@ -186,6 +182,37 @@ QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES = {
186
182
  FieldType.relation.value,
187
183
  FieldType.subtable.value,
188
184
  }
185
+ QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES_PUBLIC = sorted(
186
+ {
187
+ "address",
188
+ "attachment",
189
+ "code_block",
190
+ "q_linker",
191
+ "relation",
192
+ "subtable",
193
+ }
194
+ )
195
+ QUERY_CONDITION_SUPPORTED_FIELD_TYPES_PUBLIC = sorted(
196
+ {
197
+ "text",
198
+ "long_text",
199
+ "number",
200
+ "amount",
201
+ "date",
202
+ "datetime",
203
+ "single_select",
204
+ "multi_select",
205
+ "phone",
206
+ "email",
207
+ "boolean",
208
+ "member",
209
+ "department",
210
+ }
211
+ )
212
+ QUERY_CONDITION_FIX_HINT = (
213
+ "Remove this field from query_conditions. query_conditions only configures the frontend query panel for supported query fields. "
214
+ "Use filters for fixed saved filters, and use associated-resource match_mappings for current-record relation/report matching."
215
+ )
189
216
 
190
217
  ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL = 1
191
218
  ASSOCIATED_RESOURCE_LIMIT_TYPE_SELECT = 2
@@ -234,7 +261,6 @@ class AiBuilderFacade:
234
261
  buttons: CustomButtonTools,
235
262
  packages: PackageTools,
236
263
  views: ViewTools,
237
- workflows: WorkflowTools,
238
264
  portals: PortalTools,
239
265
  charts: QingbiReportTools,
240
266
  roles: RoleTools,
@@ -245,13 +271,216 @@ class AiBuilderFacade:
245
271
  self.buttons = buttons
246
272
  self.packages = packages
247
273
  self.views = views
248
- self.workflows = workflows
249
274
  self.portals = portals
250
275
  self.charts = charts
251
276
  self.roles = roles
252
277
  self.directory = directory
253
278
  self.solutions = solutions
254
279
 
280
+ def _with_backend_context(self, profile: str, handler):
281
+ return self.apps._run(profile, lambda _session_profile, context: handler(context))
282
+
283
+ def flow_get_schema(self, *, profile: str, schema_version: str | None = None) -> JSONObject:
284
+ normalized_args = {"schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION}
285
+ try:
286
+ payload = self._with_backend_context(
287
+ profile,
288
+ lambda context: workflow_spec_api.fetch_schema(
289
+ self.apps.backend,
290
+ context,
291
+ schema_version=schema_version,
292
+ ),
293
+ )
294
+ except (QingflowApiError, RuntimeError) as error:
295
+ api_error = _coerce_api_error(error)
296
+ return _failed_from_api_error(
297
+ "FLOW_SPEC_SCHEMA_FAILED",
298
+ api_error,
299
+ normalized_args=normalized_args,
300
+ details={},
301
+ suggested_next_call={"tool_name": "app_flow_get_schema", "arguments": {"profile": profile, **normalized_args}},
302
+ )
303
+ return {
304
+ "status": "success",
305
+ "error_code": None,
306
+ "recoverable": False,
307
+ "message": "read workflow spec schema",
308
+ "normalized_args": normalized_args,
309
+ "missing_fields": [],
310
+ "allowed_values": {},
311
+ "details": {"schema": payload},
312
+ "request_id": None,
313
+ "suggested_next_call": None,
314
+ "noop": False,
315
+ "warnings": [],
316
+ "verification": {"schema_loaded": True},
317
+ "verified": True,
318
+ "schema": payload,
319
+ }
320
+
321
+ def flow_get(
322
+ self,
323
+ *,
324
+ profile: str,
325
+ app_key: str,
326
+ version_id: str | None = None,
327
+ ) -> JSONObject:
328
+ normalized_args = {"app_key": app_key, "version_id": version_id}
329
+ permission_outcome = self._guard_app_permission(
330
+ profile=profile,
331
+ app_key=app_key,
332
+ required_permission="data_manage",
333
+ normalized_args=normalized_args,
334
+ )
335
+ if permission_outcome.block is not None:
336
+ return permission_outcome.block
337
+ try:
338
+ payload = self._with_backend_context(
339
+ profile,
340
+ lambda context: workflow_spec_api.fetch_spec(
341
+ self.apps.backend,
342
+ context,
343
+ app_key=app_key,
344
+ version_id=version_id,
345
+ ),
346
+ )
347
+ except (QingflowApiError, RuntimeError) as error:
348
+ api_error = _coerce_api_error(error)
349
+ return _failed_from_api_error(
350
+ "FLOW_SPEC_GET_FAILED",
351
+ api_error,
352
+ normalized_args=normalized_args,
353
+ details={"app_key": app_key},
354
+ suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
355
+ )
356
+ response = {
357
+ "status": "success",
358
+ "error_code": None,
359
+ "recoverable": False,
360
+ "message": "read workflow spec",
361
+ "normalized_args": normalized_args,
362
+ "missing_fields": [],
363
+ "allowed_values": {},
364
+ "details": {},
365
+ "request_id": None,
366
+ "suggested_next_call": None,
367
+ "noop": False,
368
+ "warnings": list(permission_outcome.warnings),
369
+ "verification": {"spec_loaded": True},
370
+ "verified": True,
371
+ "app_key": app_key,
372
+ }
373
+ if isinstance(payload, dict):
374
+ response.update(payload)
375
+ else:
376
+ response["result"] = payload
377
+ return _apply_permission_outcomes(response, permission_outcome)
378
+
379
+ def flow_apply(
380
+ self,
381
+ *,
382
+ profile: str,
383
+ app_key: str,
384
+ spec: dict[str, Any],
385
+ publish: bool = True,
386
+ idempotency_key: str | None = None,
387
+ schema_version: str | None = None,
388
+ ) -> JSONObject:
389
+ normalized_args = {
390
+ "app_key": app_key,
391
+ "spec": spec,
392
+ "publish": publish,
393
+ "idempotency_key": idempotency_key,
394
+ "schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
395
+ }
396
+ permission_outcomes: list[PermissionCheckOutcome] = []
397
+ permission_outcome = self._guard_app_permission(
398
+ profile=profile,
399
+ app_key=app_key,
400
+ required_permission="data_manage",
401
+ normalized_args=normalized_args,
402
+ )
403
+ if permission_outcome.block is not None:
404
+ return permission_outcome.block
405
+ permission_outcomes.append(permission_outcome)
406
+
407
+ def finalize(response: JSONObject) -> JSONObject:
408
+ return _apply_permission_outcomes(response, *permission_outcomes)
409
+
410
+ if not isinstance(spec, dict) or not spec:
411
+ return finalize(
412
+ _failed(
413
+ "FLOW_SPEC_REQUIRED",
414
+ "spec must be a non-empty WorkflowSpecDTO object",
415
+ normalized_args=normalized_args,
416
+ suggested_next_call={"tool_name": "app_flow_get_schema", "arguments": {"profile": profile}},
417
+ )
418
+ )
419
+ apply_body = {
420
+ "appKey": app_key,
421
+ "idempotencyKey": idempotency_key or str(uuid4()),
422
+ "schemaVersion": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
423
+ "spec": spec,
424
+ }
425
+ try:
426
+ apply_result = self._with_backend_context(
427
+ profile,
428
+ lambda context: workflow_spec_api.post_apply(self.apps.backend, context, apply_body=apply_body),
429
+ )
430
+ except (QingflowApiError, RuntimeError) as error:
431
+ api_error = _coerce_api_error(error)
432
+ return finalize(
433
+ _failed_from_api_error(
434
+ "FLOW_SPEC_APPLY_FAILED",
435
+ api_error,
436
+ normalized_args=normalized_args,
437
+ details={"app_key": app_key},
438
+ suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
439
+ )
440
+ )
441
+ if not isinstance(apply_result, dict):
442
+ apply_result = {"result": apply_result}
443
+ verification, verify_warnings, verified = workflow_spec_api.verify_apply_response(
444
+ input_spec=spec,
445
+ apply_result=apply_result,
446
+ )
447
+ backend_status = str(apply_result.get("status") or "SUCCESS").upper()
448
+ if backend_status not in {"SUCCESS", "OK"}:
449
+ return finalize(
450
+ _failed(
451
+ "FLOW_SPEC_APPLY_FAILED",
452
+ str(apply_result.get("message") or "workflow spec apply failed"),
453
+ normalized_args=normalized_args,
454
+ details={"apply_result": apply_result},
455
+ suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
456
+ )
457
+ )
458
+ status = "success" if verified else "partial_success"
459
+ response = {
460
+ "status": status,
461
+ "error_code": None if verified else "FLOW_SPEC_VERIFY_FAILED",
462
+ "recoverable": not verified,
463
+ "message": "applied workflow spec" if verified else "applied workflow spec; verification reported issues",
464
+ "normalized_args": normalized_args,
465
+ "missing_fields": [],
466
+ "allowed_values": {},
467
+ "details": {
468
+ "apply_result": apply_result,
469
+ "appliedSpec": apply_result.get("appliedSpec"),
470
+ "diffSummary": apply_result.get("diffSummary"),
471
+ "semanticLint": apply_result.get("semanticLint"),
472
+ },
473
+ "request_id": None,
474
+ "suggested_next_call": None if verified else {"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
475
+ "noop": False,
476
+ "warnings": verify_warnings,
477
+ "verification": verification,
478
+ "verified": verified,
479
+ "app_key": app_key,
480
+ "current_version_id": apply_result.get("currentVersionId"),
481
+ }
482
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
483
+
255
484
  def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
256
485
  requested = str(package_name or "").strip()
257
486
  if not requested:
@@ -2058,83 +2287,6 @@ class AiBuilderFacade:
2058
2287
  },
2059
2288
  }
2060
2289
 
2061
- def _normalize_flow_nodes(
2062
- self,
2063
- *,
2064
- profile: str,
2065
- current_fields: list[dict[str, Any]],
2066
- nodes: list[dict[str, Any]],
2067
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
2068
- field_name_to_field = {
2069
- str(field.get("name") or ""): field
2070
- for field in current_fields
2071
- if str(field.get("name") or "")
2072
- }
2073
- field_name_to_que_id = {
2074
- str(field.get("name") or ""): int(field.get("que_id"))
2075
- for field in current_fields
2076
- if str(field.get("name") or "") and isinstance(field.get("que_id"), int)
2077
- }
2078
- normalized_nodes: list[dict[str, Any]] = []
2079
- issues: list[dict[str, Any]] = []
2080
- for node in nodes:
2081
- if not isinstance(node, dict):
2082
- continue
2083
- normalized_node = deepcopy(node)
2084
- assignees = FlowAssigneePatch.model_validate(node.get("assignees") or {})
2085
- permissions = FlowNodePermissionsPatch.model_validate(node.get("permissions") or {})
2086
- role_resolution = self._resolve_role_references(
2087
- profile=profile,
2088
- role_ids=assignees.role_ids,
2089
- role_names=assignees.role_names,
2090
- )
2091
- member_resolution = self._resolve_member_references(
2092
- profile=profile,
2093
- member_uids=assignees.member_uids,
2094
- member_emails=assignees.member_emails,
2095
- member_names=assignees.member_names,
2096
- )
2097
- issues.extend({**issue, "node_id": node.get("id")} for issue in [*role_resolution["issues"], *member_resolution["issues"]])
2098
- editable_que_ids: list[int] = []
2099
- missing_editable_fields: list[str] = []
2100
- for field_name in permissions.editable_fields:
2101
- if field_name not in field_name_to_que_id:
2102
- missing_editable_fields.append(field_name)
2103
- else:
2104
- editable_que_ids.append(field_name_to_que_id[field_name])
2105
- if missing_editable_fields:
2106
- issues.append(
2107
- {
2108
- "node_id": node.get("id"),
2109
- "kind": "editable_fields",
2110
- "error_code": "UNKNOWN_FLOW_FIELD",
2111
- "missing_fields": missing_editable_fields,
2112
- }
2113
- )
2114
- condition_matrix, condition_issues = _build_flow_condition_matrix(
2115
- current_fields_by_name=field_name_to_field,
2116
- node=normalized_node,
2117
- )
2118
- issues.extend({**issue, "node_id": node.get("id")} for issue in condition_issues)
2119
- config_payload = deepcopy(normalized_node.get("config") or {}) if isinstance(normalized_node.get("config"), dict) else {}
2120
- if condition_matrix:
2121
- # Backend judge nodes persist branch conditions from `autoJudges`.
2122
- # Keep the legacy mirror key for internal verification logic.
2123
- config_payload["autoJudges"] = condition_matrix
2124
- config_payload["conditionFormatMatrix"] = condition_matrix
2125
- normalized_node["assignees"] = {
2126
- "member_uids": member_resolution["member_uids"],
2127
- "role_entries": role_resolution["role_entries"],
2128
- "include_sub_departs": assignees.include_sub_departs,
2129
- }
2130
- normalized_node["permissions"] = {
2131
- "editable_fields": permissions.editable_fields,
2132
- "editable_que_ids": editable_que_ids,
2133
- }
2134
- if config_payload:
2135
- normalized_node["config"] = config_payload
2136
- normalized_nodes.append(normalized_node)
2137
- return normalized_nodes, issues
2138
2290
 
2139
2291
  def _unsupported_public_flow_nodes(self, *, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
2140
2292
  unsupported: list[dict[str, Any]] = []
@@ -4378,7 +4530,7 @@ class AiBuilderFacade:
4378
4530
  "details": {"edit_version_no": None, "associated_item_ids_by_client_key": client_key_to_id, "readback_failed": False},
4379
4531
  "request_id": None,
4380
4532
  "suggested_next_call": None,
4381
- "noop": True,
4533
+ "noop": False,
4382
4534
  "warnings": [],
4383
4535
  "verification": {"associated_resources_verified": True, "associated_resource_view_configs_verified": True, "readback_loaded": True, "published": False},
4384
4536
  "verified": True,
@@ -5236,36 +5388,13 @@ class AiBuilderFacade:
5236
5388
  except (QingflowApiError, RuntimeError) as error:
5237
5389
  custom_buttons_unavailable = True
5238
5390
  custom_buttons = []
5239
- readback_errors.append(
5240
- {
5241
- "resource": "custom_buttons",
5242
- "phase": "summary",
5243
- "transport_error": _transport_error_payload(_coerce_api_error(error)),
5244
- }
5245
- )
5246
- workflow, workflow_unavailable, workflow_read_error = self._load_workflow_result(
5391
+ workflow_spec, workflow_unavailable = self._load_workflow_spec_snapshot(
5247
5392
  profile=profile,
5248
5393
  app_key=app_key,
5249
5394
  tolerate_404=True,
5250
5395
  tolerate_permission_restricted=True,
5251
- include_error=True,
5252
5396
  )
5253
- if views_read_error is not None:
5254
- readback_errors.append(
5255
- {
5256
- "resource": "views",
5257
- "phase": "summary",
5258
- "transport_error": _transport_error_payload(views_read_error),
5259
- }
5260
- )
5261
- if workflow_read_error is not None:
5262
- readback_errors.append(
5263
- {
5264
- "resource": "workflow",
5265
- "phase": "summary",
5266
- "transport_error": _transport_error_payload(workflow_read_error),
5267
- }
5268
- )
5397
+ workflow_nodes = _extract_workflow_spec_nodes(workflow_spec)
5269
5398
  verification_hints = _build_verification_hints(
5270
5399
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
5271
5400
  fields=parsed["fields"],
@@ -5321,7 +5450,7 @@ class AiBuilderFacade:
5321
5450
  charts=chart_summaries,
5322
5451
  associated_resources=associated_resources,
5323
5452
  custom_buttons=custom_buttons,
5324
- workflow_enabled=bool(workflow),
5453
+ workflow_enabled=bool(workflow_nodes),
5325
5454
  verification_hints=verification_hints,
5326
5455
  form_settings={} if schema_unavailable else _form_settings_from_schema(schema_result, parsed["fields"]),
5327
5456
  )
@@ -5731,7 +5860,7 @@ class AiBuilderFacade:
5731
5860
  return result
5732
5861
 
5733
5862
  def app_get_flow(self, *, profile: str, app_key: str) -> JSONObject:
5734
- result = self.app_read_flow_summary(profile=profile, app_key=app_key)
5863
+ result = self.flow_get(profile=profile, app_key=app_key)
5735
5864
  if result.get("status") == "success":
5736
5865
  result["message"] = "read app flow config"
5737
5866
  return result
@@ -5912,41 +6041,6 @@ class AiBuilderFacade:
5912
6041
  **response.model_dump(mode="json"),
5913
6042
  }
5914
6043
 
5915
- def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
5916
- try:
5917
- workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
5918
- except (QingflowApiError, RuntimeError) as error:
5919
- api_error = _coerce_api_error(error)
5920
- return _failed_from_api_error(
5921
- "FLOW_READ_FAILED",
5922
- api_error,
5923
- normalized_args={"app_key": app_key},
5924
- details={"app_key": app_key},
5925
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
5926
- )
5927
- response = AppFlowReadResponse(
5928
- app_key=app_key,
5929
- enabled=bool(workflow),
5930
- nodes=_summarize_workflow_nodes(workflow),
5931
- transitions=[],
5932
- )
5933
- return {
5934
- "status": "success",
5935
- "error_code": None,
5936
- "recoverable": False,
5937
- "message": "read app flow summary",
5938
- "normalized_args": {"app_key": app_key},
5939
- "missing_fields": [],
5940
- "allowed_values": {},
5941
- "details": {},
5942
- "request_id": None,
5943
- "suggested_next_call": None,
5944
- "noop": False,
5945
- "warnings": [_warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable")] if workflow_unavailable else [],
5946
- "verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
5947
- "verified": not workflow_unavailable,
5948
- **response.model_dump(mode="json"),
5949
- }
5950
6044
 
5951
6045
  def app_read_charts_summary(self, *, profile: str, app_key: str) -> JSONObject:
5952
6046
  try:
@@ -7399,154 +7493,6 @@ class AiBuilderFacade:
7399
7493
  "verification": {"field_count": len(current_names)},
7400
7494
  }
7401
7495
 
7402
- def app_flow_plan(self, *, profile: str, request: FlowPlanRequest) -> JSONObject:
7403
- nodes = [node.model_dump(mode="json") for node in request.nodes]
7404
- transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
7405
- if request.preset is not None:
7406
- preset_nodes, preset_transitions = _build_flow_preset(request.preset)
7407
- nodes, transitions = _merge_flow_graph(
7408
- base_nodes=preset_nodes,
7409
- base_transitions=preset_transitions,
7410
- override_nodes=nodes,
7411
- override_transitions=transitions,
7412
- )
7413
- fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
7414
- if fields_result.get("status") == "failed":
7415
- return fields_result
7416
- current_fields = fields_result.get("fields", [])
7417
- normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
7418
- public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
7419
- unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
7420
- if unsupported_nodes:
7421
- return _failed(
7422
- "FLOW_NODE_TYPE_UNSUPPORTED",
7423
- "app_flow_apply currently supports only linear workflows; branch and condition nodes are disabled because the backend workflow route is not front-end stable for these node types.",
7424
- normalized_args={
7425
- "app_key": request.app_key,
7426
- "mode": str(request.mode or "replace"),
7427
- "preset": request.preset.value if request.preset else None,
7428
- "nodes": public_nodes,
7429
- "transitions": transitions,
7430
- },
7431
- details={
7432
- "unsupported_nodes": unsupported_nodes,
7433
- "supported_node_types": sorted(STABLE_PUBLIC_FLOW_NODE_TYPES),
7434
- "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
7435
- },
7436
- suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
7437
- )
7438
- if resolution_issues:
7439
- first_issue = resolution_issues[0]
7440
- suggested_call = None
7441
- if first_issue.get("kind", "").startswith("role"):
7442
- suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
7443
- elif first_issue.get("kind", "").startswith("member"):
7444
- suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
7445
- elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
7446
- suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": request.app_key}}
7447
- return _failed(
7448
- first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
7449
- "workflow contains unresolved assignees or field permissions",
7450
- normalized_args={
7451
- "app_key": request.app_key,
7452
- "mode": str(request.mode or "replace"),
7453
- "preset": request.preset.value if request.preset else None,
7454
- "nodes": public_nodes,
7455
- "transitions": transitions,
7456
- },
7457
- details={"issues": resolution_issues},
7458
- suggested_next_call=suggested_call,
7459
- )
7460
- status_field_present = _infer_status_field_id(current_fields) is not None
7461
- node_types = {str(node.get("type") or "") for node in normalized_nodes}
7462
- assignee_required_nodes = [
7463
- node.get("id")
7464
- for node in normalized_nodes
7465
- if str(node.get("type") or "") in {"approve", "fill", "copy"}
7466
- and not (
7467
- (node.get("assignees") or {}).get("role_entries")
7468
- or (node.get("assignees") or {}).get("member_uids")
7469
- )
7470
- ]
7471
- if assignee_required_nodes:
7472
- return _failed(
7473
- "FLOW_ASSIGNEE_REQUIRED",
7474
- "workflow approval/fill/copy nodes must declare at least one role or member assignee",
7475
- normalized_args={
7476
- "app_key": request.app_key,
7477
- "mode": str(request.mode or "replace"),
7478
- "preset": request.preset.value if request.preset else None,
7479
- "nodes": public_nodes,
7480
- "transitions": transitions,
7481
- },
7482
- details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
7483
- suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
7484
- )
7485
- workflow = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
7486
- if workflow.get("status") == "failed":
7487
- workflow["normalized_args"] = {
7488
- "app_key": request.app_key,
7489
- "mode": str(request.mode or "replace"),
7490
- "nodes": public_nodes,
7491
- "transitions": transitions,
7492
- }
7493
- workflow["suggested_next_call"] = {
7494
- "tool_name": "app_flow_plan",
7495
- "arguments": {
7496
- "profile": profile,
7497
- "app_key": request.app_key,
7498
- "mode": "replace",
7499
- "nodes": public_nodes,
7500
- "transitions": transitions,
7501
- },
7502
- }
7503
- return workflow
7504
- if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
7505
- return _failed(
7506
- "FLOW_DEPENDENCY_MISSING",
7507
- "workflow requires an explicit status field",
7508
- normalized_args={
7509
- "app_key": request.app_key,
7510
- "mode": str(request.mode or "replace"),
7511
- "nodes": public_nodes,
7512
- "transitions": transitions,
7513
- },
7514
- details={"missing_dependencies": ["status field"]},
7515
- missing_fields=["status"],
7516
- suggested_next_call={
7517
- "tool_name": "app_schema_apply",
7518
- "arguments": {
7519
- "profile": profile,
7520
- "app_key": request.app_key,
7521
- "create_if_missing": False,
7522
- "add_fields": [{"name": "状态", "type": "single_select", "options": ["草稿", "进行中", "已完成"], "required": True}],
7523
- "update_fields": [],
7524
- "remove_fields": [],
7525
- },
7526
- },
7527
- )
7528
- normalized_args = {
7529
- "app_key": request.app_key,
7530
- "mode": str(request.mode or "replace"),
7531
- "nodes": public_nodes,
7532
- "transitions": transitions,
7533
- }
7534
- return {
7535
- "status": "success",
7536
- "error_code": None,
7537
- "recoverable": False,
7538
- "message": "planned workflow patch",
7539
- "normalized_args": normalized_args,
7540
- "missing_fields": [],
7541
- "allowed_values": {"presets": [preset.value for preset in FlowPreset]},
7542
- "details": {},
7543
- "request_id": None,
7544
- "flow_diff_preview": {"mode": "replace", "node_count": len([node for node in nodes if node.get("type") != "end"])},
7545
- "dependency_issues": [],
7546
- "suggested_next_call": {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
7547
- "noop": False,
7548
- "verification": {"status_field_present": status_field_present},
7549
- }
7550
7496
 
7551
7497
  def app_views_plan(self, *, profile: str, request: ViewsPlanRequest) -> JSONObject:
7552
7498
  fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
@@ -7839,8 +7785,9 @@ class AiBuilderFacade:
7839
7785
  "form_settings": _form_settings_from_schema(schema_result, parsed["fields"]),
7840
7786
  "layout": parsed["layout"],
7841
7787
  "flow_summary": {
7842
- "enabled": bool(state["workflow"]),
7843
- "nodes": _summarize_workflow_nodes(state["workflow"]),
7788
+ "enabled": bool(_extract_workflow_spec_nodes(state["workflow_spec"])),
7789
+ "nodes": _summarize_workflow_spec_nodes(_extract_workflow_spec_nodes(state["workflow_spec"])),
7790
+ "source": "workflow_spec",
7844
7791
  },
7845
7792
  "views_summary": {
7846
7793
  "views": _summarize_views(state["views"]),
@@ -7851,7 +7798,7 @@ class AiBuilderFacade:
7851
7798
  "base": base_result,
7852
7799
  "schema": schema_result,
7853
7800
  "views": state["views"],
7854
- "workflow": state["workflow"],
7801
+ "workflow_spec": state["workflow_spec"],
7855
7802
  }
7856
7803
  return response
7857
7804
 
@@ -8266,7 +8213,16 @@ class AiBuilderFacade:
8266
8213
  relation_field_count = _count_relation_fields(current_fields)
8267
8214
  relation_limit_verified = relation_field_count <= 1
8268
8215
  relation_warnings = (
8269
- [_warning("RELATION_FIELD_LIMIT_RISK", "multiple relation fields are not a stable backend capability", relation_field_count=relation_field_count)]
8216
+ [
8217
+ _warning(
8218
+ "RELATION_FIELD_LIMIT_RISK",
8219
+ "multiple relation fields were written and verified, but this backend capability can be unstable; do not downgrade to text unless the write fails or relation readback mismatches",
8220
+ severity="warning",
8221
+ relation_field_count=relation_field_count,
8222
+ relation_fields_created=True,
8223
+ do_not_downgrade_to_text_unless_apply_failed=True,
8224
+ )
8225
+ ]
8270
8226
  if not relation_limit_verified
8271
8227
  else []
8272
8228
  )
@@ -8397,6 +8353,8 @@ class AiBuilderFacade:
8397
8353
  "app_key": target.app_key,
8398
8354
  "field_diff": {"added": added, "updated": updated, "removed": removed},
8399
8355
  "relation_field_count": relation_field_count,
8356
+ "relation_fields_created": False,
8357
+ "recommended_modeling_fallback": "Keep the primary relation field as relation; use text/reference summary fields only for secondary cross-object links, and report the downgrade as partial.",
8400
8358
  "transport_error": {
8401
8359
  "http_status": api_error.http_status,
8402
8360
  "backend_code": api_error.backend_code,
@@ -9041,228 +8999,19 @@ class AiBuilderFacade:
9041
8999
  *,
9042
9000
  profile: str,
9043
9001
  app_key: str,
9044
- nodes: list[dict[str, Any]],
9045
- transitions: list[dict[str, Any]],
9046
- mode: str = "replace",
9002
+ spec: dict[str, Any],
9047
9003
  publish: bool = True,
9004
+ idempotency_key: str | None = None,
9005
+ schema_version: str | None = None,
9048
9006
  ) -> JSONObject:
9049
- normalized_args = {
9050
- "app_key": app_key,
9051
- "mode": mode,
9052
- "nodes": nodes,
9053
- "transitions": transitions,
9054
- "publish": publish,
9055
- }
9056
- permission_outcomes: list[PermissionCheckOutcome] = []
9057
- permission_outcome = self._guard_app_permission(
9007
+ return self.flow_apply(
9058
9008
  profile=profile,
9059
9009
  app_key=app_key,
9060
- required_permission="edit_app",
9061
- normalized_args=normalized_args,
9010
+ spec=spec,
9011
+ publish=publish,
9012
+ idempotency_key=idempotency_key,
9013
+ schema_version=schema_version,
9062
9014
  )
9063
- if permission_outcome.block is not None:
9064
- return permission_outcome.block
9065
- permission_outcomes.append(permission_outcome)
9066
-
9067
- def finalize(response: JSONObject) -> JSONObject:
9068
- return _apply_permission_outcomes(response, *permission_outcomes)
9069
-
9070
- if mode != "replace":
9071
- return finalize(_failed(
9072
- "UNSUPPORTED_FLOW_MODE",
9073
- "only mode='replace' is supported",
9074
- normalized_args=normalized_args,
9075
- allowed_values={"modes": ["replace"]},
9076
- suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
9077
- ))
9078
- try:
9079
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9080
- schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9081
- except (QingflowApiError, RuntimeError) as error:
9082
- api_error = _coerce_api_error(error)
9083
- return finalize(_failed_from_api_error(
9084
- "FLOW_READ_FAILED",
9085
- api_error,
9086
- normalized_args=normalized_args,
9087
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
9088
- suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
9089
- ))
9090
- app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
9091
- entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
9092
- current_fields = _parse_schema(schema)["fields"]
9093
- normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
9094
- public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
9095
- unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
9096
- normalized_args["nodes"] = public_nodes
9097
- if unsupported_nodes:
9098
- return _failed(
9099
- "FLOW_NODE_TYPE_UNSUPPORTED",
9100
- "app_flow_apply currently supports only linear workflows; branch and condition nodes are disabled because the backend workflow route is not front-end stable for these node types.",
9101
- normalized_args=normalized_args,
9102
- details={
9103
- "unsupported_nodes": unsupported_nodes,
9104
- "supported_node_types": sorted(STABLE_PUBLIC_FLOW_NODE_TYPES),
9105
- "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
9106
- },
9107
- suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
9108
- )
9109
- if resolution_issues:
9110
- first_issue = resolution_issues[0]
9111
- suggested_call = None
9112
- if first_issue.get("kind", "").startswith("role"):
9113
- suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
9114
- elif first_issue.get("kind", "").startswith("member"):
9115
- suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
9116
- elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
9117
- suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}}
9118
- return _failed(
9119
- first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
9120
- "workflow contains unresolved assignees or field permissions",
9121
- normalized_args=normalized_args,
9122
- details={"issues": resolution_issues},
9123
- suggested_next_call=suggested_call,
9124
- )
9125
- assignee_required_nodes = [
9126
- node.get("id")
9127
- for node in normalized_nodes
9128
- if str(node.get("type") or "") in {"approve", "fill", "copy"}
9129
- and not (
9130
- (node.get("assignees") or {}).get("role_entries")
9131
- or (node.get("assignees") or {}).get("member_uids")
9132
- )
9133
- ]
9134
- if assignee_required_nodes:
9135
- return _failed(
9136
- "FLOW_ASSIGNEE_REQUIRED",
9137
- "workflow approval/fill/copy nodes must declare at least one role or member assignee",
9138
- normalized_args=normalized_args,
9139
- details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
9140
- suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
9141
- )
9142
- workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
9143
- if workflow_spec.get("status") == "failed":
9144
- workflow_spec["normalized_args"] = normalized_args
9145
- workflow_spec.setdefault("request_id", None)
9146
- workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
9147
- return workflow_spec
9148
- desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
9149
- build_id = f"facade-flow-{uuid4().hex[:12]}"
9150
- previous_build_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
9151
- temporary_build_home: str | None = None
9152
- if previous_build_home is None:
9153
- temporary_build_home = tempfile.mkdtemp(prefix="qingflow-mcp-build-", dir="/tmp")
9154
- os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
9155
- try:
9156
- assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
9157
- manifest = default_manifest()
9158
- manifest["solution_name"] = base.get("formTitle") or app_key
9159
- manifest["preferences"]["create_package"] = False
9160
- manifest["preferences"]["create_portal"] = False
9161
- manifest["preferences"]["create_navigation"] = False
9162
- manifest["entities"] = [entity]
9163
- assembly.set_manifest(manifest)
9164
- artifacts = default_artifacts()
9165
- artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
9166
- assembly.set_artifacts(artifacts)
9167
- flow_stage_spec = {
9168
- "solution_name": manifest["solution_name"],
9169
- "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
9170
- }
9171
- assembly.set_stage_spec("app_flow", flow_stage_spec)
9172
- stage = self.solutions.solution_build_flow(
9173
- profile=profile,
9174
- mode="apply",
9175
- build_id=build_id,
9176
- flow_spec=flow_stage_spec,
9177
- publish=False,
9178
- run_label=None,
9179
- repair_patch={},
9180
- )
9181
- finally:
9182
- if previous_build_home is None:
9183
- os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
9184
- if stage.get("status") != "success":
9185
- failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
9186
- failed["normalized_args"] = normalized_args
9187
- suggested_next_call = failed.get("suggested_next_call")
9188
- if not isinstance(suggested_next_call, dict):
9189
- suggested_next_call = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
9190
- elif suggested_next_call.get("tool_name") == "app_flow_plan":
9191
- arguments = suggested_next_call.get("arguments")
9192
- if not isinstance(arguments, dict):
9193
- arguments = {}
9194
- arguments.setdefault("profile", profile)
9195
- arguments.setdefault("app_key", app_key)
9196
- arguments.setdefault("mode", mode)
9197
- arguments.setdefault("nodes", public_nodes)
9198
- arguments.setdefault("transitions", transitions)
9199
- suggested_next_call["tool_name"] = "app_flow_apply"
9200
- suggested_next_call["arguments"] = arguments
9201
- failed["suggested_next_call"] = suggested_next_call
9202
- return finalize(failed)
9203
- verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
9204
- workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
9205
- current_workflow=verified_nodes,
9206
- requested_nodes=normalized_nodes,
9207
- )
9208
- branch_structure_verified = bool(verified_nodes) and _workflow_branch_structure_verified(
9209
- current_workflow=verified_nodes,
9210
- requested_nodes=normalized_nodes,
9211
- )
9212
- workflow_verified = workflow_structure_verified and branch_structure_verified
9213
- warnings: list[dict[str, Any]] = []
9214
- if verified_nodes_unavailable:
9215
- status = "partial_success"
9216
- error_code = "FLOW_READBACK_PENDING"
9217
- recoverable = True
9218
- message = "applied workflow patch; flow readback pending"
9219
- suggested_next_call = {"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}}
9220
- elif workflow_verified:
9221
- status = "success"
9222
- error_code = None
9223
- recoverable = False
9224
- message = "applied workflow patch"
9225
- suggested_next_call = None
9226
- else:
9227
- status = "partial_success"
9228
- error_code = "FLOW_BRANCH_VERIFICATION_FAILED" if workflow_structure_verified and not branch_structure_verified else "FLOW_VERIFICATION_FAILED"
9229
- recoverable = True
9230
- message = (
9231
- "applied workflow patch; branch or condition structure did not fully verify"
9232
- if workflow_structure_verified and not branch_structure_verified
9233
- else "applied workflow patch; flow readback did not confirm the requested workflow"
9234
- )
9235
- suggested_next_call = {"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}}
9236
- if not branch_structure_verified:
9237
- warnings.append(_warning("WORKFLOW_BRANCH_STRUCTURE_UNVERIFIED", "branch or condition structure was written, but MCP could not fully verify downstream lane structure"))
9238
- response = {
9239
- "status": status,
9240
- "error_code": error_code,
9241
- "recoverable": recoverable,
9242
- "message": message,
9243
- "normalized_args": normalized_args,
9244
- "missing_fields": [],
9245
- "allowed_values": {"modes": ["replace"]},
9246
- "details": {},
9247
- "request_id": None,
9248
- "suggested_next_call": suggested_next_call,
9249
- "noop": False,
9250
- "warnings": warnings,
9251
- "verification": {
9252
- "workflow_verified": workflow_verified,
9253
- "workflow_structure_verified": workflow_structure_verified,
9254
- "branch_structure_verified": branch_structure_verified,
9255
- "workflow_read_unavailable": verified_nodes_unavailable,
9256
- },
9257
- "app_key": app_key,
9258
- "app_name": app_name,
9259
- "flow_diff": {"mode": "replace", "node_count": desired_node_count},
9260
- "verified": workflow_verified,
9261
- "write_executed": True,
9262
- "write_succeeded": True,
9263
- "safe_to_retry": False,
9264
- }
9265
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9266
9015
 
9267
9016
  def app_views_apply(
9268
9017
  self,
@@ -10154,6 +9903,12 @@ class AiBuilderFacade:
10154
9903
  "backend_code": api_error.backend_code,
10155
9904
  "category": api_error.category,
10156
9905
  },
9906
+ **_view_apply_failure_diagnostics(
9907
+ patch=patch,
9908
+ current_fields_by_name=current_fields_by_name,
9909
+ backend_code=api_error.backend_code,
9910
+ operation_phase=operation_phase,
9911
+ ),
10157
9912
  },
10158
9913
  }
10159
9914
  failed_views.append(failure_entry)
@@ -11224,6 +10979,56 @@ class AiBuilderFacade:
11224
10979
  normalized_args["upsert_charts"] = [patch.model_dump(mode="json") for patch in upsert_charts]
11225
10980
  normalized_args["patch_results"] = patch_results
11226
10981
 
10982
+ if len(upsert_charts) > CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE:
10983
+ batching_context = _chart_apply_batching_context(
10984
+ request=request,
10985
+ upsert_charts=upsert_charts,
10986
+ batch_size=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
10987
+ )
10988
+ first_batch = batching_context["suggested_batch_payloads"][0]
10989
+ return finalize({
10990
+ "status": "failed",
10991
+ "error_code": "CHART_UPSERT_BATCH_TOO_LARGE",
10992
+ "recoverable": True,
10993
+ "message": (
10994
+ f"upsert_charts contains {len(upsert_charts)} items; split into batches of "
10995
+ f"{CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE} or fewer before writing"
10996
+ ),
10997
+ "normalized_args": normalized_args,
10998
+ "missing_fields": [],
10999
+ "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
11000
+ "details": batching_context,
11001
+ "request_id": None,
11002
+ "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **first_batch}},
11003
+ "backend_code": None,
11004
+ "http_status": None,
11005
+ "noop": False,
11006
+ "warnings": [
11007
+ _warning(
11008
+ "CHART_UPSERT_BATCH_SIZE_BLOCKED",
11009
+ "large chart upsert batches are blocked before write to avoid timeout and unknown write state",
11010
+ upsert_count=len(upsert_charts),
11011
+ max_upsert_count=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
11012
+ )
11013
+ ],
11014
+ "verification": {
11015
+ "charts_verified": False,
11016
+ "readback_unavailable": False,
11017
+ "readback_before_retry": False,
11018
+ "chart_delete_readback_results": [],
11019
+ "chart_order_verified": True,
11020
+ "chart_list_source": existing_chart_list_source,
11021
+ },
11022
+ "app_key": app_key,
11023
+ "app_name": app_name,
11024
+ "chart_results": [],
11025
+ "verified": False,
11026
+ "write_executed": False,
11027
+ "write_succeeded": False,
11028
+ "safe_to_retry": True,
11029
+ "next_action": "split_upsert_charts_and_retry",
11030
+ })
11031
+
11227
11032
  chart_results: list[dict[str, Any]] = []
11228
11033
  created_ids: list[str] = []
11229
11034
  updated_ids: list[str] = []
@@ -11510,6 +11315,15 @@ class AiBuilderFacade:
11510
11315
  primary_failure = failed_items[0]
11511
11316
  primary_error_code = str(primary_failure.get("error_code") or "").strip()
11512
11317
  primary_message = str(primary_failure.get("message") or "").strip()
11318
+ retry_context = _chart_apply_retry_context(
11319
+ request=request,
11320
+ failed_items=failed_items,
11321
+ created_ids=created_ids,
11322
+ updated_ids=updated_ids,
11323
+ removed_ids=removed_ids,
11324
+ reordered=reordered,
11325
+ write_executed=write_executed,
11326
+ )
11513
11327
  return finalize({
11514
11328
  "status": "partial_success" if successful_changes else "failed",
11515
11329
  "error_code": "CHART_APPLY_PARTIAL" if successful_changes else primary_error_code or "CHART_APPLY_FAILED",
@@ -11520,6 +11334,7 @@ class AiBuilderFacade:
11520
11334
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
11521
11335
  "details": {
11522
11336
  "per_chart_results": chart_results,
11337
+ **retry_context,
11523
11338
  **({"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {}),
11524
11339
  },
11525
11340
  "request_id": failed_items[0].get("request_id") or (readback_error.request_id if readback_error is not None else None),
@@ -11536,6 +11351,7 @@ class AiBuilderFacade:
11536
11351
  "verification": {
11537
11352
  "charts_verified": False if failed_items else verified,
11538
11353
  "readback_unavailable": any_readback_unavailable,
11354
+ "readback_before_retry": bool(write_executed),
11539
11355
  "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
11540
11356
  "chart_order_verified": False if request.reorder_chart_ids else True,
11541
11357
  "chart_list_source": readback_list_source or existing_chart_list_source,
@@ -11546,6 +11362,7 @@ class AiBuilderFacade:
11546
11362
  "verified": False if failed_items else verified,
11547
11363
  "write_executed": write_executed,
11548
11364
  "write_succeeded": write_succeeded,
11365
+ **({"write_may_have_succeeded": True, "next_action": "readback_before_retry"} if write_executed else {}),
11549
11366
  "safe_to_retry": not write_executed,
11550
11367
  })
11551
11368
  result_verified = verified or noop
@@ -12376,7 +12193,7 @@ class AiBuilderFacade:
12376
12193
  result = legacy_normalized or normalized_views
12377
12194
  return (result, False, None) if include_error else (result, False)
12378
12195
 
12379
- def _load_workflow_result(
12196
+ def _load_workflow_spec_snapshot(
12380
12197
  self,
12381
12198
  *,
12382
12199
  profile: str,
@@ -12386,24 +12203,34 @@ class AiBuilderFacade:
12386
12203
  include_error: bool = False,
12387
12204
  ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
12388
12205
  try:
12389
- workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
12206
+ payload = self._with_backend_context(
12207
+ profile,
12208
+ lambda context: workflow_spec_api.fetch_spec(
12209
+ self.apps.backend,
12210
+ context,
12211
+ app_key=app_key,
12212
+ ),
12213
+ )
12390
12214
  except (QingflowApiError, RuntimeError) as error:
12391
12215
  api_error = _coerce_api_error(error)
12392
12216
  if tolerate_404 and (
12393
12217
  api_error.http_status == 404
12394
12218
  or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
12395
12219
  ):
12396
- return ([], True, api_error) if include_error else ([], True)
12220
+ return {}, True
12397
12221
  raise
12398
- result = workflow.get("result")
12399
- return (result, False, None) if include_error else (result, False)
12222
+ return payload if isinstance(payload, dict) else {}, False
12400
12223
 
12401
12224
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
12402
12225
  state = self._load_base_schema_state(profile=profile, app_key=app_key)
12403
12226
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12404
- workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
12227
+ workflow_spec, workflow_unavailable = self._load_workflow_spec_snapshot(
12228
+ profile=profile,
12229
+ app_key=app_key,
12230
+ tolerate_404=True,
12231
+ )
12405
12232
  state["views"] = views
12406
- state["workflow"] = workflow
12233
+ state["workflow_spec"] = workflow_spec
12407
12234
  state["views_unavailable"] = views_unavailable
12408
12235
  state["workflow_unavailable"] = workflow_unavailable
12409
12236
  return state
@@ -15682,6 +15509,155 @@ def _public_chart_filter_groups_from_qingbi_config(config: dict[str, Any]) -> li
15682
15509
  return groups
15683
15510
 
15684
15511
 
15512
+ def _chart_apply_retry_context(
15513
+ *,
15514
+ request: ChartApplyRequest,
15515
+ failed_items: list[dict[str, Any]],
15516
+ created_ids: list[str],
15517
+ updated_ids: list[str],
15518
+ removed_ids: list[str],
15519
+ reordered: bool,
15520
+ write_executed: bool,
15521
+ ) -> dict[str, Any]:
15522
+ failed_chart_names = [
15523
+ str(item.get("name") or "").strip()
15524
+ for item in failed_items
15525
+ if str(item.get("name") or "").strip()
15526
+ ]
15527
+ failed_delete_ids = [
15528
+ str(item.get("chart_id") or "").strip()
15529
+ for item in failed_items
15530
+ if str(item.get("operation") or "") == "delete" and str(item.get("chart_id") or "").strip()
15531
+ ]
15532
+ reorder_failed = any(str(item.get("operation") or "") == "reorder" for item in failed_items)
15533
+ failed_name_set = set(failed_chart_names)
15534
+ retry_payload: dict[str, Any] = {"app_key": request.app_key}
15535
+ retry_upserts = [
15536
+ patch.model_dump(mode="json", exclude_none=True)
15537
+ for patch in request.upsert_charts
15538
+ if patch.name in failed_name_set
15539
+ ]
15540
+ if retry_upserts:
15541
+ retry_payload["upsert_charts"] = retry_upserts
15542
+ if failed_delete_ids:
15543
+ retry_payload["remove_chart_ids"] = failed_delete_ids
15544
+ if reorder_failed and request.reorder_chart_ids:
15545
+ retry_payload["reorder_chart_ids"] = list(request.reorder_chart_ids)
15546
+ has_retry_payload = any(key in retry_payload for key in ("upsert_charts", "remove_chart_ids", "reorder_chart_ids"))
15547
+ context: dict[str, Any] = {
15548
+ "created_chart_ids": list(created_ids),
15549
+ "updated_chart_ids": list(updated_ids),
15550
+ "removed_chart_ids": list(removed_ids),
15551
+ "failed_chart_names": failed_chart_names,
15552
+ "failed_delete_chart_ids": failed_delete_ids,
15553
+ "readback_first": bool(write_executed),
15554
+ "retry_rule": (
15555
+ "read app charts before retrying; retry only the failed chart payloads if they are still missing or unverified"
15556
+ if write_executed
15557
+ else "fix the failed chart payloads and retry only those items"
15558
+ ),
15559
+ }
15560
+ if has_retry_payload:
15561
+ context["suggested_retry_payload"] = retry_payload
15562
+ return context
15563
+
15564
+
15565
+ def _chart_apply_batching_context(
15566
+ *,
15567
+ request: ChartApplyRequest,
15568
+ upsert_charts: list[ChartUpsertPatch],
15569
+ batch_size: int,
15570
+ ) -> dict[str, Any]:
15571
+ suggested_batch_payloads: list[dict[str, Any]] = []
15572
+ for start in range(0, len(upsert_charts), batch_size):
15573
+ batch = upsert_charts[start : start + batch_size]
15574
+ suggested_batch_payloads.append(
15575
+ {
15576
+ "app_key": request.app_key,
15577
+ "upsert_charts": [patch.model_dump(mode="json", exclude_none=True) for patch in batch],
15578
+ }
15579
+ )
15580
+
15581
+ followup_payload: dict[str, Any] = {"app_key": request.app_key}
15582
+ if request.remove_chart_ids:
15583
+ followup_payload["remove_chart_ids"] = list(request.remove_chart_ids)
15584
+ if request.reorder_chart_ids:
15585
+ followup_payload["reorder_chart_ids"] = list(request.reorder_chart_ids)
15586
+ if len(followup_payload) > 1:
15587
+ suggested_batch_payloads.append(followup_payload)
15588
+
15589
+ return {
15590
+ "upsert_count": len(upsert_charts),
15591
+ "max_upsert_count": batch_size,
15592
+ "write_executed": False,
15593
+ "readback_first": False,
15594
+ "retry_rule": "run suggested_batch_payloads one at a time; do not submit the original large upsert_charts array",
15595
+ "suggested_batch_payloads": suggested_batch_payloads,
15596
+ }
15597
+
15598
+
15599
+ def _view_apply_failure_diagnostics(
15600
+ *,
15601
+ patch: ViewUpsertPatch,
15602
+ current_fields_by_name: dict[str, dict[str, Any]],
15603
+ backend_code: Any,
15604
+ operation_phase: str,
15605
+ ) -> dict[str, Any]:
15606
+ references: list[tuple[str, str]] = []
15607
+
15608
+ def add_reference(name: Any, source: str) -> None:
15609
+ text = str(name or "").strip()
15610
+ if text:
15611
+ references.append((text, source))
15612
+
15613
+ for column in patch.columns:
15614
+ add_reference(column, "columns")
15615
+ add_reference(patch.group_by, "group_by")
15616
+ add_reference(patch.start_field, "start_field")
15617
+ add_reference(patch.end_field, "end_field")
15618
+ add_reference(patch.title_field, "title_field")
15619
+ for rule in patch.filters:
15620
+ add_reference(rule.field_name, "filters")
15621
+
15622
+ seen: set[str] = set()
15623
+ field_entries: list[dict[str, Any]] = []
15624
+ for field_name, source in references:
15625
+ key = f"{field_name}\0{source}"
15626
+ if key in seen:
15627
+ continue
15628
+ seen.add(key)
15629
+ field = current_fields_by_name.get(field_name) or {}
15630
+ field_entries.append(
15631
+ {
15632
+ "field_name": field_name,
15633
+ "source": source,
15634
+ "field_id": field.get("field_id"),
15635
+ "que_id": field.get("que_id"),
15636
+ "field_type": field.get("type"),
15637
+ "que_type": field.get("que_type"),
15638
+ "exists_in_schema": bool(field),
15639
+ }
15640
+ )
15641
+ diagnostics: dict[str, Any] = {
15642
+ "operation_phase": operation_phase,
15643
+ "field_level_diagnostics": field_entries,
15644
+ }
15645
+ if backend_code_value_int(backend_code) == 40038:
15646
+ diagnostics.update(
15647
+ {
15648
+ "suspected_fields": field_entries,
15649
+ "recovery_hint": "Do not delete app fields or recreate the app. First retry the view with the smallest required columns; then add non-critical columns back one by one.",
15650
+ "recommended_minimal_retry": {
15651
+ "name": patch.name,
15652
+ "type": patch.type.value,
15653
+ "columns": [field_entries[0]["field_name"]] if field_entries else [],
15654
+ **({"filters": [rule.model_dump(mode="json", exclude_none=True) for rule in patch.filters]} if patch.filters else {}),
15655
+ },
15656
+ }
15657
+ )
15658
+ return diagnostics
15659
+
15660
+
15685
15661
  def _public_chart_group_by_from_qingbi_config(config: dict[str, Any]) -> list[str]:
15686
15662
  fields: list[dict[str, Any]] = []
15687
15663
  for key in ("selectedDimensions", "xDimensions", "yDimensions", "selectedTime"):
@@ -20236,30 +20212,6 @@ def _build_layout_preset_sections(*, preset: LayoutPreset, field_names: list[str
20236
20212
  return sections
20237
20213
 
20238
20214
 
20239
- def _build_flow_preset(preset: FlowPreset) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
20240
- if preset == FlowPreset.basic_fill_then_approve:
20241
- nodes = [
20242
- {"id": "start", "type": "start", "name": "发起"},
20243
- {"id": "fill_1", "type": "fill", "name": "补充信息"},
20244
- {"id": "approve_1", "type": "approve", "name": "审批"},
20245
- {"id": "end", "type": "end", "name": "结束"},
20246
- ]
20247
- transitions = [
20248
- {"from": "start", "to": "fill_1"},
20249
- {"from": "fill_1", "to": "approve_1"},
20250
- {"from": "approve_1", "to": "end"},
20251
- ]
20252
- return nodes, transitions
20253
- nodes = [
20254
- {"id": "start", "type": "start", "name": "发起"},
20255
- {"id": "approve_1", "type": "approve", "name": "审批"},
20256
- {"id": "end", "type": "end", "name": "结束"},
20257
- ]
20258
- transitions = [
20259
- {"from": "start", "to": "approve_1"},
20260
- {"from": "approve_1", "to": "end"},
20261
- ]
20262
- return nodes, transitions
20263
20215
 
20264
20216
 
20265
20217
  def _merge_flow_graph(
@@ -22114,144 +22066,35 @@ def _apply_row_widths(row: list[dict[str, Any]]) -> None:
22114
22066
  question["queWidth"] = width + (1 if index < remainder else 0)
22115
22067
 
22116
22068
 
22117
- def _summarize_workflow_nodes(result: Any) -> list[dict[str, Any]]:
22118
- nodes = []
22119
- if isinstance(result, dict):
22120
- iterable = result.values()
22121
- elif isinstance(result, list):
22122
- iterable = result
22123
- else:
22124
- iterable = []
22125
- for item in iterable:
22126
- if not isinstance(item, dict):
22127
- continue
22128
- node_id = _coerce_positive_int(item.get("auditNodeId"))
22129
- name = item.get("auditNodeName")
22130
- if node_id is None or not name:
22131
- continue
22132
- nodes.append({"id": node_id, "name": name, "type": item.get("type"), "deal_type": item.get("dealType")})
22133
- return nodes
22134
-
22135
-
22136
- def _workflow_node_iterable(result: Any) -> list[dict[str, Any]]:
22137
- if isinstance(result, dict):
22138
- iterable = result.values()
22139
- elif isinstance(result, list):
22140
- iterable = result
22141
- else:
22142
- iterable = []
22143
- return [item for item in iterable if isinstance(item, dict)]
22144
-
22145
-
22146
- def _workflow_condition_matrix_value(node: dict[str, Any]) -> list[Any]:
22147
- config = node.get("config")
22148
- if isinstance(config, dict):
22149
- matrix = config.get("autoJudges")
22150
- if isinstance(matrix, list):
22151
- return deepcopy(matrix)
22152
- matrix = config.get("conditionFormatMatrix")
22153
- if isinstance(matrix, list):
22154
- return deepcopy(matrix)
22155
- matrix = node.get("autoJudges")
22156
- if isinstance(matrix, list):
22157
- return deepcopy(matrix)
22158
- matrix = node.get("conditionFormatMatrix")
22159
- if isinstance(matrix, list):
22160
- return deepcopy(matrix)
22069
+ def _extract_workflow_spec_nodes(payload: Any) -> list[dict[str, Any]]:
22070
+ if not isinstance(payload, dict):
22071
+ return []
22072
+ spec = payload.get("spec")
22073
+ if isinstance(spec, dict):
22074
+ nodes = spec.get("nodes")
22075
+ if isinstance(nodes, list):
22076
+ return [item for item in nodes if isinstance(item, dict)]
22077
+ nodes = payload.get("nodes")
22078
+ if isinstance(nodes, list):
22079
+ return [item for item in nodes if isinstance(item, dict)]
22161
22080
  return []
22162
22081
 
22163
22082
 
22164
- def _workflow_branch_structure_verified(*, current_workflow: Any, requested_nodes: list[dict[str, Any]]) -> bool:
22165
- requested_branch_like = [
22166
- node
22167
- for node in requested_nodes
22168
- if str(node.get("type") or "").strip().lower() in {"branch", "condition"}
22169
- ]
22170
- if not requested_branch_like:
22171
- return True
22172
- actual_nodes = _workflow_node_iterable(current_workflow)
22173
- actual_branch_like = [
22174
- node
22175
- for node in actual_nodes
22176
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("dealType")) in {"branch", "condition"}
22177
- ]
22178
- if len(actual_branch_like) < len(requested_branch_like):
22179
- return False
22180
- actual_condition_nodes = [
22181
- node
22182
- for node in actual_nodes
22183
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("dealType")) == "condition"
22184
- ]
22185
- for requested in requested_branch_like:
22186
- if str(requested.get("type") or "").strip().lower() != "condition":
22187
- continue
22188
- expected_matrix = _workflow_condition_matrix_value(requested)
22189
- if not expected_matrix:
22083
+ def _summarize_workflow_spec_nodes(spec_nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
22084
+ summaries: list[dict[str, Any]] = []
22085
+ for item in spec_nodes:
22086
+ node_id = str(item.get("id") or "").strip()
22087
+ name = str(item.get("name") or "").strip()
22088
+ if not node_id and not name:
22190
22089
  continue
22191
- requested_name = str(requested.get("name") or "").strip()
22192
- match = next(
22193
- (
22194
- node
22195
- for node in actual_condition_nodes
22196
- if str(node.get("auditNodeName") or node.get("nodeName") or "").strip() == requested_name
22197
- ),
22198
- None,
22199
- )
22200
- if match is None or not _workflow_condition_matrix_value(match):
22201
- return False
22202
- return True
22203
-
22204
-
22205
- def _workflow_nodes_semantically_equal(*, current_workflow: Any, requested_nodes: list[dict[str, Any]]) -> bool:
22206
- current_nodes = _summarize_workflow_nodes(current_workflow)
22207
- current_effective = [
22208
- node
22209
- for node in current_nodes
22210
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")) not in {"start", "end"}
22211
- ]
22212
- requested_effective = [
22213
- node for node in requested_nodes if str(node.get("type") or "") not in {"start", "end"}
22214
- ]
22215
- if not current_effective or not requested_effective or len(current_effective) != len(requested_effective):
22216
- return False
22217
- current_signatures = sorted(
22218
- (
22219
- _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")),
22220
- str(node.get("name") or "").strip(),
22221
- )
22222
- for node in current_effective
22223
- )
22224
- requested_signatures = sorted(
22225
- (
22226
- str(node.get("type") or "").strip().lower(),
22227
- str(node.get("name") or "").strip(),
22090
+ summaries.append(
22091
+ {
22092
+ "id": node_id or None,
22093
+ "name": name or None,
22094
+ "type": item.get("type"),
22095
+ }
22228
22096
  )
22229
- for node in requested_effective
22230
- )
22231
- return current_signatures == requested_signatures
22232
-
22233
-
22234
- def _normalize_existing_workflow_node_type(raw_type: Any, deal_type: Any) -> str:
22235
- normalized = "" if raw_type is None else str(raw_type).strip().lower()
22236
- if normalized in {"audit", "approve"}:
22237
- return "approve"
22238
- if normalized in {"fill", "copy", "branch", "condition", "webhook", "start", "end"}:
22239
- return normalized
22240
- if normalized in {"1"}:
22241
- return "branch"
22242
- if normalized in {"2"}:
22243
- return "condition"
22244
- if normalized in {"3"} or deal_type == 10:
22245
- return "webhook"
22246
- if normalized in {"0"} and deal_type == 0:
22247
- return "approve"
22248
- if normalized in {"0"} and deal_type == 1:
22249
- return "fill"
22250
- if normalized in {"0"} and deal_type == 2:
22251
- return "copy"
22252
- if normalized in {"0"} or deal_type == 3:
22253
- return "start"
22254
- return normalized or "unknown"
22097
+ return summaries
22255
22098
 
22256
22099
 
22257
22100
  def _summarize_views(result: Any) -> list[dict[str, Any]]:
@@ -23449,98 +23292,6 @@ def _field_id_for_name(fields: list[dict[str, Any]], name: str) -> str:
23449
23292
  raise KeyError(name)
23450
23293
 
23451
23294
 
23452
- def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: list[dict[str, Any]]) -> JSONObject:
23453
- node_map = {str(node.get("id") or ""): node for node in nodes if isinstance(node, dict) and node.get("id")}
23454
- if not node_map:
23455
- return _failed("INVALID_FLOW", "nodes must be a non-empty list", suggested_next_call=None)
23456
- start_nodes = [node_id for node_id, node in node_map.items() if node.get("type") == "start"]
23457
- if len(start_nodes) != 1:
23458
- return _failed("INVALID_FLOW", "flow must contain exactly one start node", suggested_next_call=None)
23459
- inbound: dict[str, list[str]] = {node_id: [] for node_id in node_map}
23460
- outbound: dict[str, list[str]] = {node_id: [] for node_id in node_map}
23461
- for transition in transitions:
23462
- if not isinstance(transition, dict):
23463
- continue
23464
- source = str(transition.get("from") or "")
23465
- target = str(transition.get("to") or "")
23466
- if source not in node_map or target not in node_map:
23467
- return _failed("INVALID_FLOW_EDGE", "transition references unknown node", details={"transition": transition}, suggested_next_call=None)
23468
- outbound[source].append(target)
23469
- inbound[target].append(source)
23470
- branch_lane_nodes: dict[str, dict[str, Any]] = {}
23471
- for node_id, node in node_map.items():
23472
- if str(node.get("type") or "") != "condition":
23473
- continue
23474
- if len(inbound[node_id]) != 1:
23475
- return _failed(
23476
- "INVALID_FLOW_EDGE",
23477
- f"condition lane '{node_id}' must have exactly one inbound transition",
23478
- details={"node_id": node_id, "inbound": inbound[node_id]},
23479
- suggested_next_call=None,
23480
- )
23481
- parent_id = inbound[node_id][0]
23482
- if node_map[parent_id].get("type") != "branch":
23483
- continue
23484
- branch_lane_nodes[node_id] = {
23485
- "branch_parent_id": parent_id,
23486
- "branch_index": outbound[parent_id].index(node_id) + 1,
23487
- }
23488
- internal_nodes = []
23489
- for node_id, node in node_map.items():
23490
- node_type = str(node.get("type") or "")
23491
- if node_type == "end":
23492
- continue
23493
- if node_type != "start" and len(inbound[node_id]) != 1:
23494
- return _failed(
23495
- "INVALID_FLOW_EDGE",
23496
- f"node '{node_id}' must have exactly one inbound transition",
23497
- details={"node_id": node_id, "inbound": inbound[node_id]},
23498
- suggested_next_call=None,
23499
- )
23500
- config_payload = deepcopy(node.get("config") or {}) if isinstance(node.get("config"), dict) else {}
23501
- assignees = node.get("assignees") or {}
23502
- if node_id in branch_lane_nodes:
23503
- lane_meta = branch_lane_nodes[node_id]
23504
- config_payload["__lane_only__"] = True
23505
- payload = {
23506
- "node_id": f"__branch_lane__{lane_meta['branch_parent_id']}__{lane_meta['branch_index']}",
23507
- "name": node.get("name") or node_id,
23508
- "node_type": "condition",
23509
- "parent_node_id": lane_meta["branch_parent_id"],
23510
- "branch_parent_id": lane_meta["branch_parent_id"],
23511
- "branch_index": lane_meta["branch_index"],
23512
- }
23513
- else:
23514
- payload = {
23515
- "node_id": node_id,
23516
- "name": node.get("name") or node_id,
23517
- "node_type": "audit" if node_type == "approve" else node_type,
23518
- }
23519
- if node_type != "start":
23520
- parent_id = inbound[node_id][0]
23521
- lane_parent = branch_lane_nodes.get(parent_id)
23522
- if lane_parent is not None:
23523
- payload["parent_node_id"] = lane_parent["branch_parent_id"]
23524
- payload["branch_parent_id"] = lane_parent["branch_parent_id"]
23525
- payload["branch_index"] = lane_parent["branch_index"]
23526
- else:
23527
- payload["parent_node_id"] = parent_id
23528
- if node_map[parent_id].get("type") == "branch":
23529
- payload["branch_parent_id"] = parent_id
23530
- payload["branch_index"] = outbound[parent_id].index(node_id) + 1
23531
- permissions = node.get("permissions") or {}
23532
- editable_que_ids = permissions.get("editable_que_ids") or []
23533
- if editable_que_ids:
23534
- config_payload["editableQueIds"] = editable_que_ids
23535
- if config_payload:
23536
- payload["config"] = config_payload
23537
- if assignees:
23538
- payload["assignees"] = deepcopy(assignees)
23539
- internal_nodes.append(payload)
23540
- return {
23541
- "status": "success",
23542
- "workflow": {"enabled": True, "nodes": internal_nodes},
23543
- }
23544
23295
 
23545
23296
 
23546
23297
  def _build_flow_condition_matrix(
@@ -23737,6 +23488,9 @@ def _resolve_query_condition_field(
23737
23488
  "field": raw,
23738
23489
  "parent_field": subfield_parent.get("name"),
23739
23490
  "message": "subtable subfields cannot be used as view query conditions",
23491
+ "fix_hint": QUERY_CONDITION_FIX_HINT,
23492
+ "supported_field_types": QUERY_CONDITION_SUPPORTED_FIELD_TYPES_PUBLIC,
23493
+ "unsupported_field_types": QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES_PUBLIC,
23740
23494
  }
23741
23495
  return {}, {
23742
23496
  "error_code": "QUERY_CONDITION_FIELD_NOT_FOUND",
@@ -23753,6 +23507,9 @@ def _resolve_query_condition_field(
23753
23507
  "field": field.get("name"),
23754
23508
  "field_type": field_type,
23755
23509
  "message": "this field type is not supported by the frontend query condition panel",
23510
+ "fix_hint": QUERY_CONDITION_FIX_HINT,
23511
+ "supported_field_types": QUERY_CONDITION_SUPPORTED_FIELD_TYPES_PUBLIC,
23512
+ "unsupported_field_types": QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES_PUBLIC,
23756
23513
  }
23757
23514
  que_id = _coerce_positive_int(field.get("que_id"))
23758
23515
  if que_id is None:
@@ -23762,6 +23519,8 @@ def _resolve_query_condition_field(
23762
23519
  "missing_fields": [],
23763
23520
  "field": field.get("name"),
23764
23521
  "message": "query condition field has no backend queId",
23522
+ "fix_hint": QUERY_CONDITION_FIX_HINT,
23523
+ "supported_field_types": QUERY_CONDITION_SUPPORTED_FIELD_TYPES_PUBLIC,
23765
23524
  }
23766
23525
  return field, None
23767
23526
 
@@ -25638,92 +25397,6 @@ def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
25638
25397
  return None
25639
25398
 
25640
25399
 
25641
- def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: str, entity: dict[str, Any]) -> JSONObject:
25642
- stage_error_code = str(stage.get("error_code") or "FLOW_APPLY_FAILED")
25643
- detail_text = _extract_stage_failure_text(stage)
25644
- request_id = stage.get("request_id")
25645
- backend_code = stage.get("backend_code")
25646
- http_status = stage.get("http_status")
25647
- public_stage_result = _public_stage_result(stage)
25648
- if request_id is None and isinstance(public_stage_result.get("errors"), list) and public_stage_result["errors"]:
25649
- first_error = public_stage_result["errors"][0]
25650
- if isinstance(first_error, dict):
25651
- request_id = first_error.get("request_id")
25652
- backend_code = first_error.get("backend_code")
25653
- http_status = first_error.get("http_status")
25654
- lowered_detail = detail_text.lower()
25655
- if "must declare status field" in detail_text:
25656
- return _failed(
25657
- "STATUS_FIELD_REQUIRED",
25658
- detail_text,
25659
- details={
25660
- "app_key": app_key,
25661
- "entity_id": entity.get("entity_id"),
25662
- "existing_fields": entity.get("fields") or [],
25663
- "stage_result": public_stage_result,
25664
- },
25665
- suggested_next_call={
25666
- "tool_name": "app_schema_apply",
25667
- "arguments": {
25668
- "profile": profile,
25669
- "app_key": app_key,
25670
- "create_if_missing": False,
25671
- "add_fields": [
25672
- {
25673
- "name": "状态",
25674
- "type": "single_select",
25675
- "options": ["草稿", "进行中", "已完成"],
25676
- "required": True,
25677
- }
25678
- ],
25679
- "update_fields": [],
25680
- "remove_fields": [],
25681
- },
25682
- },
25683
- request_id=request_id,
25684
- backend_code=backend_code,
25685
- http_status=http_status,
25686
- )
25687
- if (
25688
- "run solution_build_app first" in lowered_detail
25689
- or "run solution_build_app_flow first" in lowered_detail
25690
- or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
25691
- ):
25692
- return _failed(
25693
- "FLOW_STAGE_CONTEXT_MISSING",
25694
- "workflow apply lost the app context required by the internal flow builder",
25695
- details={
25696
- "app_key": app_key,
25697
- "entity_id": entity.get("entity_id"),
25698
- "existing_fields": entity.get("fields") or [],
25699
- "internal_detail": detail_text,
25700
- "stage_result": public_stage_result,
25701
- },
25702
- suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
25703
- request_id=request_id,
25704
- backend_code=backend_code,
25705
- http_status=http_status,
25706
- )
25707
- message = detail_text or "failed to apply workflow patch"
25708
- details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
25709
- public_http_status = http_status
25710
- if http_status == 404:
25711
- message = "workflow write route is unavailable for this app in the current route"
25712
- details["transport_error"] = {
25713
- "http_status": http_status,
25714
- "backend_code": backend_code,
25715
- "category": "http",
25716
- }
25717
- public_http_status = None
25718
- return _failed(
25719
- stage_error_code,
25720
- message,
25721
- details=details,
25722
- suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
25723
- request_id=request_id,
25724
- backend_code=backend_code,
25725
- http_status=public_http_status,
25726
- )
25727
25400
 
25728
25401
 
25729
25402
  def _extract_stage_failure_text(stage: JSONObject) -> str: