@qingflow-tech/qingflow-app-builder-mcp 1.0.44 → 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.
Files changed (40) hide show
  1. package/README.md +4 -2
  2. package/npm/bin/qingflow-app-builder-mcp.mjs +31 -2
  3. package/npm/lib/runtime.mjs +43 -2
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-builder-code-integrations/SKILL.md +1 -1
  7. package/skills/qingflow-mcp-setup/SKILL.md +115 -0
  8. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  9. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  10. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  11. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  12. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  13. package/skills/qingflow-workflow-builder/SKILL.md +98 -0
  14. package/skills/qingflow-workflow-builder/manifest.yaml +8 -0
  15. package/skills/qingflow-workflow-builder/references/01-overview.md +45 -0
  16. package/skills/qingflow-workflow-builder/references/02-update-mode.md +53 -0
  17. package/skills/qingflow-workflow-builder/references/03-flow-patterns.md +57 -0
  18. package/skills/qingflow-workflow-builder/references/04-stage1-business-modeling.md +131 -0
  19. package/skills/qingflow-workflow-builder/references/05-stage2-members-roles.md +29 -0
  20. package/skills/qingflow-workflow-builder/references/06-stage3-build-spec.md +165 -0
  21. package/skills/qingflow-workflow-builder/references/07-stage4-validate-spec.md +33 -0
  22. package/skills/qingflow-workflow-builder/references/08-stage5-apply-verify.md +51 -0
  23. package/skills/qingflow-workflow-builder/references/09-stage6-summary.md +88 -0
  24. package/skills/qingflow-workflow-builder/references/10-node-config-reference.md +93 -0
  25. package/skills/qingflow-workflow-builder/references/11-troubleshooting.md +15 -0
  26. package/skills/qingflow-workflow-builder/scripts/diff_flow_spec.py +275 -0
  27. package/skills/qingflow-workflow-builder/scripts/validate_flow_spec.py +605 -0
  28. package/src/qingflow_mcp/__init__.py +1 -1
  29. package/src/qingflow_mcp/builder_facade/models.py +0 -39
  30. package/src/qingflow_mcp/builder_facade/service.py +262 -862
  31. package/src/qingflow_mcp/builder_facade/workflow_spec.py +111 -0
  32. package/src/qingflow_mcp/cli/commands/builder.py +44 -12
  33. package/src/qingflow_mcp/public_surface.py +2 -0
  34. package/src/qingflow_mcp/server_app_builder.py +16 -8
  35. package/src/qingflow_mcp/solution/compiler/__init__.py +1 -3
  36. package/src/qingflow_mcp/solution/executor.py +3 -133
  37. package/src/qingflow_mcp/tools/ai_builder_tools.py +92 -233
  38. package/src/qingflow_mcp/tools/solution_tools.py +30 -2
  39. package/src/qingflow_mcp/tools/workflow_tools.py +3 -31
  40. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +0 -173
@@ -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,8 +93,6 @@ 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
@@ -266,7 +261,6 @@ class AiBuilderFacade:
266
261
  buttons: CustomButtonTools,
267
262
  packages: PackageTools,
268
263
  views: ViewTools,
269
- workflows: WorkflowTools,
270
264
  portals: PortalTools,
271
265
  charts: QingbiReportTools,
272
266
  roles: RoleTools,
@@ -277,13 +271,216 @@ class AiBuilderFacade:
277
271
  self.buttons = buttons
278
272
  self.packages = packages
279
273
  self.views = views
280
- self.workflows = workflows
281
274
  self.portals = portals
282
275
  self.charts = charts
283
276
  self.roles = roles
284
277
  self.directory = directory
285
278
  self.solutions = solutions
286
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
+
287
484
  def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
288
485
  requested = str(package_name or "").strip()
289
486
  if not requested:
@@ -2090,83 +2287,6 @@ class AiBuilderFacade:
2090
2287
  },
2091
2288
  }
2092
2289
 
2093
- def _normalize_flow_nodes(
2094
- self,
2095
- *,
2096
- profile: str,
2097
- current_fields: list[dict[str, Any]],
2098
- nodes: list[dict[str, Any]],
2099
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
2100
- field_name_to_field = {
2101
- str(field.get("name") or ""): field
2102
- for field in current_fields
2103
- if str(field.get("name") or "")
2104
- }
2105
- field_name_to_que_id = {
2106
- str(field.get("name") or ""): int(field.get("que_id"))
2107
- for field in current_fields
2108
- if str(field.get("name") or "") and isinstance(field.get("que_id"), int)
2109
- }
2110
- normalized_nodes: list[dict[str, Any]] = []
2111
- issues: list[dict[str, Any]] = []
2112
- for node in nodes:
2113
- if not isinstance(node, dict):
2114
- continue
2115
- normalized_node = deepcopy(node)
2116
- assignees = FlowAssigneePatch.model_validate(node.get("assignees") or {})
2117
- permissions = FlowNodePermissionsPatch.model_validate(node.get("permissions") or {})
2118
- role_resolution = self._resolve_role_references(
2119
- profile=profile,
2120
- role_ids=assignees.role_ids,
2121
- role_names=assignees.role_names,
2122
- )
2123
- member_resolution = self._resolve_member_references(
2124
- profile=profile,
2125
- member_uids=assignees.member_uids,
2126
- member_emails=assignees.member_emails,
2127
- member_names=assignees.member_names,
2128
- )
2129
- issues.extend({**issue, "node_id": node.get("id")} for issue in [*role_resolution["issues"], *member_resolution["issues"]])
2130
- editable_que_ids: list[int] = []
2131
- missing_editable_fields: list[str] = []
2132
- for field_name in permissions.editable_fields:
2133
- if field_name not in field_name_to_que_id:
2134
- missing_editable_fields.append(field_name)
2135
- else:
2136
- editable_que_ids.append(field_name_to_que_id[field_name])
2137
- if missing_editable_fields:
2138
- issues.append(
2139
- {
2140
- "node_id": node.get("id"),
2141
- "kind": "editable_fields",
2142
- "error_code": "UNKNOWN_FLOW_FIELD",
2143
- "missing_fields": missing_editable_fields,
2144
- }
2145
- )
2146
- condition_matrix, condition_issues = _build_flow_condition_matrix(
2147
- current_fields_by_name=field_name_to_field,
2148
- node=normalized_node,
2149
- )
2150
- issues.extend({**issue, "node_id": node.get("id")} for issue in condition_issues)
2151
- config_payload = deepcopy(normalized_node.get("config") or {}) if isinstance(normalized_node.get("config"), dict) else {}
2152
- if condition_matrix:
2153
- # Backend judge nodes persist branch conditions from `autoJudges`.
2154
- # Keep the legacy mirror key for internal verification logic.
2155
- config_payload["autoJudges"] = condition_matrix
2156
- config_payload["conditionFormatMatrix"] = condition_matrix
2157
- normalized_node["assignees"] = {
2158
- "member_uids": member_resolution["member_uids"],
2159
- "role_entries": role_resolution["role_entries"],
2160
- "include_sub_departs": assignees.include_sub_departs,
2161
- }
2162
- normalized_node["permissions"] = {
2163
- "editable_fields": permissions.editable_fields,
2164
- "editable_que_ids": editable_que_ids,
2165
- }
2166
- if config_payload:
2167
- normalized_node["config"] = config_payload
2168
- normalized_nodes.append(normalized_node)
2169
- return normalized_nodes, issues
2170
2290
 
2171
2291
  def _unsupported_public_flow_nodes(self, *, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
2172
2292
  unsupported: list[dict[str, Any]] = []
@@ -5268,36 +5388,13 @@ class AiBuilderFacade:
5268
5388
  except (QingflowApiError, RuntimeError) as error:
5269
5389
  custom_buttons_unavailable = True
5270
5390
  custom_buttons = []
5271
- readback_errors.append(
5272
- {
5273
- "resource": "custom_buttons",
5274
- "phase": "summary",
5275
- "transport_error": _transport_error_payload(_coerce_api_error(error)),
5276
- }
5277
- )
5278
- workflow, workflow_unavailable, workflow_read_error = self._load_workflow_result(
5391
+ workflow_spec, workflow_unavailable = self._load_workflow_spec_snapshot(
5279
5392
  profile=profile,
5280
5393
  app_key=app_key,
5281
5394
  tolerate_404=True,
5282
5395
  tolerate_permission_restricted=True,
5283
- include_error=True,
5284
5396
  )
5285
- if views_read_error is not None:
5286
- readback_errors.append(
5287
- {
5288
- "resource": "views",
5289
- "phase": "summary",
5290
- "transport_error": _transport_error_payload(views_read_error),
5291
- }
5292
- )
5293
- if workflow_read_error is not None:
5294
- readback_errors.append(
5295
- {
5296
- "resource": "workflow",
5297
- "phase": "summary",
5298
- "transport_error": _transport_error_payload(workflow_read_error),
5299
- }
5300
- )
5397
+ workflow_nodes = _extract_workflow_spec_nodes(workflow_spec)
5301
5398
  verification_hints = _build_verification_hints(
5302
5399
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
5303
5400
  fields=parsed["fields"],
@@ -5353,7 +5450,7 @@ class AiBuilderFacade:
5353
5450
  charts=chart_summaries,
5354
5451
  associated_resources=associated_resources,
5355
5452
  custom_buttons=custom_buttons,
5356
- workflow_enabled=bool(workflow),
5453
+ workflow_enabled=bool(workflow_nodes),
5357
5454
  verification_hints=verification_hints,
5358
5455
  form_settings={} if schema_unavailable else _form_settings_from_schema(schema_result, parsed["fields"]),
5359
5456
  )
@@ -5763,7 +5860,7 @@ class AiBuilderFacade:
5763
5860
  return result
5764
5861
 
5765
5862
  def app_get_flow(self, *, profile: str, app_key: str) -> JSONObject:
5766
- result = self.app_read_flow_summary(profile=profile, app_key=app_key)
5863
+ result = self.flow_get(profile=profile, app_key=app_key)
5767
5864
  if result.get("status") == "success":
5768
5865
  result["message"] = "read app flow config"
5769
5866
  return result
@@ -5944,41 +6041,6 @@ class AiBuilderFacade:
5944
6041
  **response.model_dump(mode="json"),
5945
6042
  }
5946
6043
 
5947
- def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
5948
- try:
5949
- workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
5950
- except (QingflowApiError, RuntimeError) as error:
5951
- api_error = _coerce_api_error(error)
5952
- return _failed_from_api_error(
5953
- "FLOW_READ_FAILED",
5954
- api_error,
5955
- normalized_args={"app_key": app_key},
5956
- details={"app_key": app_key},
5957
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
5958
- )
5959
- response = AppFlowReadResponse(
5960
- app_key=app_key,
5961
- enabled=bool(workflow),
5962
- nodes=_summarize_workflow_nodes(workflow),
5963
- transitions=[],
5964
- )
5965
- return {
5966
- "status": "success",
5967
- "error_code": None,
5968
- "recoverable": False,
5969
- "message": "read app flow summary",
5970
- "normalized_args": {"app_key": app_key},
5971
- "missing_fields": [],
5972
- "allowed_values": {},
5973
- "details": {},
5974
- "request_id": None,
5975
- "suggested_next_call": None,
5976
- "noop": False,
5977
- "warnings": [_warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable")] if workflow_unavailable else [],
5978
- "verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
5979
- "verified": not workflow_unavailable,
5980
- **response.model_dump(mode="json"),
5981
- }
5982
6044
 
5983
6045
  def app_read_charts_summary(self, *, profile: str, app_key: str) -> JSONObject:
5984
6046
  try:
@@ -7431,159 +7493,6 @@ class AiBuilderFacade:
7431
7493
  "verification": {"field_count": len(current_names)},
7432
7494
  }
7433
7495
 
7434
- def app_flow_plan(self, *, profile: str, request: FlowPlanRequest) -> JSONObject:
7435
- nodes = [node.model_dump(mode="json") for node in request.nodes]
7436
- transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
7437
- if request.preset is not None:
7438
- preset_nodes, preset_transitions = _build_flow_preset(request.preset)
7439
- nodes, transitions = _merge_flow_graph(
7440
- base_nodes=preset_nodes,
7441
- base_transitions=preset_transitions,
7442
- override_nodes=nodes,
7443
- override_transitions=transitions,
7444
- )
7445
- fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
7446
- if fields_result.get("status") == "failed":
7447
- return fields_result
7448
- current_fields = fields_result.get("fields", [])
7449
- normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
7450
- public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
7451
- unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
7452
- if unsupported_nodes:
7453
- return _failed(
7454
- "FLOW_NODE_TYPE_UNSUPPORTED",
7455
- "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.",
7456
- normalized_args={
7457
- "app_key": request.app_key,
7458
- "mode": str(request.mode or "replace"),
7459
- "preset": request.preset.value if request.preset else None,
7460
- "nodes": public_nodes,
7461
- "transitions": transitions,
7462
- },
7463
- details={
7464
- "unsupported_nodes": unsupported_nodes,
7465
- "supported_node_types": sorted(STABLE_PUBLIC_FLOW_NODE_TYPES),
7466
- "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
7467
- },
7468
- suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
7469
- )
7470
- if resolution_issues:
7471
- first_issue = resolution_issues[0]
7472
- suggested_call = None
7473
- if first_issue.get("kind", "").startswith("role"):
7474
- suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
7475
- elif first_issue.get("kind", "").startswith("member"):
7476
- suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
7477
- elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
7478
- suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": request.app_key}}
7479
- return _failed(
7480
- first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
7481
- "workflow contains unresolved assignees or field permissions",
7482
- normalized_args={
7483
- "app_key": request.app_key,
7484
- "mode": str(request.mode or "replace"),
7485
- "preset": request.preset.value if request.preset else None,
7486
- "nodes": public_nodes,
7487
- "transitions": transitions,
7488
- },
7489
- details={"issues": resolution_issues},
7490
- suggested_next_call=suggested_call,
7491
- )
7492
- status_field_present = _infer_status_field_id(current_fields) is not None
7493
- node_types = {str(node.get("type") or "") for node in normalized_nodes}
7494
- assignee_required_nodes = [
7495
- node.get("id")
7496
- for node in normalized_nodes
7497
- if str(node.get("type") or "") in {"approve", "fill", "copy"}
7498
- and not (
7499
- (node.get("assignees") or {}).get("role_entries")
7500
- or (node.get("assignees") or {}).get("member_uids")
7501
- )
7502
- ]
7503
- if assignee_required_nodes:
7504
- return _failed(
7505
- "FLOW_ASSIGNEE_REQUIRED",
7506
- "workflow approval/fill/copy nodes must declare at least one role or member assignee",
7507
- normalized_args={
7508
- "app_key": request.app_key,
7509
- "mode": str(request.mode or "replace"),
7510
- "preset": request.preset.value if request.preset else None,
7511
- "nodes": public_nodes,
7512
- "transitions": transitions,
7513
- },
7514
- details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
7515
- suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
7516
- )
7517
- workflow = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
7518
- if workflow.get("status") == "failed":
7519
- workflow["normalized_args"] = {
7520
- "app_key": request.app_key,
7521
- "mode": str(request.mode or "replace"),
7522
- "nodes": public_nodes,
7523
- "transitions": transitions,
7524
- }
7525
- workflow["suggested_next_call"] = {
7526
- "tool_name": "app_flow_plan",
7527
- "arguments": {
7528
- "profile": profile,
7529
- "app_key": request.app_key,
7530
- "mode": "replace",
7531
- "nodes": public_nodes,
7532
- "transitions": transitions,
7533
- },
7534
- }
7535
- return workflow
7536
- if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
7537
- return _failed(
7538
- "FLOW_DEPENDENCY_MISSING",
7539
- "workflow requires an explicit status field",
7540
- normalized_args={
7541
- "app_key": request.app_key,
7542
- "mode": str(request.mode or "replace"),
7543
- "nodes": public_nodes,
7544
- "transitions": transitions,
7545
- },
7546
- details={
7547
- "missing_dependencies": ["status field"],
7548
- "fix_hint": "Add an explicit business status select field before applying approval workflows. Do not create platform system fields such as 当前流程状态.",
7549
- "recommended_field_names": ["状态", "处理状态", "审批状态", "工单状态", "流程阶段"],
7550
- "forbidden_system_field_names": ["当前流程状态", "当前处理人", "当前处理节点", "流程标题"],
7551
- },
7552
- missing_fields=["status"],
7553
- suggested_next_call={
7554
- "tool_name": "app_schema_apply",
7555
- "arguments": {
7556
- "profile": profile,
7557
- "app_key": request.app_key,
7558
- "create_if_missing": False,
7559
- "add_fields": [{"name": "状态", "type": "select", "options": ["草稿", "进行中", "已完成"], "required": True}],
7560
- "update_fields": [],
7561
- "remove_fields": [],
7562
- },
7563
- },
7564
- )
7565
- normalized_args = {
7566
- "app_key": request.app_key,
7567
- "mode": str(request.mode or "replace"),
7568
- "nodes": public_nodes,
7569
- "transitions": transitions,
7570
- }
7571
- return {
7572
- "status": "success",
7573
- "error_code": None,
7574
- "recoverable": False,
7575
- "message": "planned workflow patch",
7576
- "normalized_args": normalized_args,
7577
- "missing_fields": [],
7578
- "allowed_values": {"presets": [preset.value for preset in FlowPreset]},
7579
- "details": {},
7580
- "request_id": None,
7581
- "flow_diff_preview": {"mode": "replace", "node_count": len([node for node in nodes if node.get("type") != "end"])},
7582
- "dependency_issues": [],
7583
- "suggested_next_call": {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
7584
- "noop": False,
7585
- "verification": {"status_field_present": status_field_present},
7586
- }
7587
7496
 
7588
7497
  def app_views_plan(self, *, profile: str, request: ViewsPlanRequest) -> JSONObject:
7589
7498
  fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
@@ -7876,8 +7785,9 @@ class AiBuilderFacade:
7876
7785
  "form_settings": _form_settings_from_schema(schema_result, parsed["fields"]),
7877
7786
  "layout": parsed["layout"],
7878
7787
  "flow_summary": {
7879
- "enabled": bool(state["workflow"]),
7880
- "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",
7881
7791
  },
7882
7792
  "views_summary": {
7883
7793
  "views": _summarize_views(state["views"]),
@@ -7888,7 +7798,7 @@ class AiBuilderFacade:
7888
7798
  "base": base_result,
7889
7799
  "schema": schema_result,
7890
7800
  "views": state["views"],
7891
- "workflow": state["workflow"],
7801
+ "workflow_spec": state["workflow_spec"],
7892
7802
  }
7893
7803
  return response
7894
7804
 
@@ -9089,228 +8999,19 @@ class AiBuilderFacade:
9089
8999
  *,
9090
9000
  profile: str,
9091
9001
  app_key: str,
9092
- nodes: list[dict[str, Any]],
9093
- transitions: list[dict[str, Any]],
9094
- mode: str = "replace",
9002
+ spec: dict[str, Any],
9095
9003
  publish: bool = True,
9004
+ idempotency_key: str | None = None,
9005
+ schema_version: str | None = None,
9096
9006
  ) -> JSONObject:
9097
- normalized_args = {
9098
- "app_key": app_key,
9099
- "mode": mode,
9100
- "nodes": nodes,
9101
- "transitions": transitions,
9102
- "publish": publish,
9103
- }
9104
- permission_outcomes: list[PermissionCheckOutcome] = []
9105
- permission_outcome = self._guard_app_permission(
9007
+ return self.flow_apply(
9106
9008
  profile=profile,
9107
9009
  app_key=app_key,
9108
- required_permission="edit_app",
9109
- normalized_args=normalized_args,
9010
+ spec=spec,
9011
+ publish=publish,
9012
+ idempotency_key=idempotency_key,
9013
+ schema_version=schema_version,
9110
9014
  )
9111
- if permission_outcome.block is not None:
9112
- return permission_outcome.block
9113
- permission_outcomes.append(permission_outcome)
9114
-
9115
- def finalize(response: JSONObject) -> JSONObject:
9116
- return _apply_permission_outcomes(response, *permission_outcomes)
9117
-
9118
- if mode != "replace":
9119
- return finalize(_failed(
9120
- "UNSUPPORTED_FLOW_MODE",
9121
- "only mode='replace' is supported",
9122
- normalized_args=normalized_args,
9123
- allowed_values={"modes": ["replace"]},
9124
- suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
9125
- ))
9126
- try:
9127
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9128
- schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9129
- except (QingflowApiError, RuntimeError) as error:
9130
- api_error = _coerce_api_error(error)
9131
- return finalize(_failed_from_api_error(
9132
- "FLOW_READ_FAILED",
9133
- api_error,
9134
- normalized_args=normalized_args,
9135
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
9136
- suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
9137
- ))
9138
- app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
9139
- entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
9140
- current_fields = _parse_schema(schema)["fields"]
9141
- normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
9142
- public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
9143
- unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
9144
- normalized_args["nodes"] = public_nodes
9145
- if unsupported_nodes:
9146
- return _failed(
9147
- "FLOW_NODE_TYPE_UNSUPPORTED",
9148
- "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.",
9149
- normalized_args=normalized_args,
9150
- details={
9151
- "unsupported_nodes": unsupported_nodes,
9152
- "supported_node_types": sorted(STABLE_PUBLIC_FLOW_NODE_TYPES),
9153
- "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
9154
- },
9155
- suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
9156
- )
9157
- if resolution_issues:
9158
- first_issue = resolution_issues[0]
9159
- suggested_call = None
9160
- if first_issue.get("kind", "").startswith("role"):
9161
- suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
9162
- elif first_issue.get("kind", "").startswith("member"):
9163
- suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
9164
- elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
9165
- suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}}
9166
- return _failed(
9167
- first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
9168
- "workflow contains unresolved assignees or field permissions",
9169
- normalized_args=normalized_args,
9170
- details={"issues": resolution_issues},
9171
- suggested_next_call=suggested_call,
9172
- )
9173
- assignee_required_nodes = [
9174
- node.get("id")
9175
- for node in normalized_nodes
9176
- if str(node.get("type") or "") in {"approve", "fill", "copy"}
9177
- and not (
9178
- (node.get("assignees") or {}).get("role_entries")
9179
- or (node.get("assignees") or {}).get("member_uids")
9180
- )
9181
- ]
9182
- if assignee_required_nodes:
9183
- return _failed(
9184
- "FLOW_ASSIGNEE_REQUIRED",
9185
- "workflow approval/fill/copy nodes must declare at least one role or member assignee",
9186
- normalized_args=normalized_args,
9187
- details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
9188
- suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
9189
- )
9190
- workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
9191
- if workflow_spec.get("status") == "failed":
9192
- workflow_spec["normalized_args"] = normalized_args
9193
- workflow_spec.setdefault("request_id", None)
9194
- workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
9195
- return workflow_spec
9196
- desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
9197
- build_id = f"facade-flow-{uuid4().hex[:12]}"
9198
- previous_build_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
9199
- temporary_build_home: str | None = None
9200
- if previous_build_home is None:
9201
- temporary_build_home = tempfile.mkdtemp(prefix="qingflow-mcp-build-", dir="/tmp")
9202
- os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
9203
- try:
9204
- assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
9205
- manifest = default_manifest()
9206
- manifest["solution_name"] = base.get("formTitle") or app_key
9207
- manifest["preferences"]["create_package"] = False
9208
- manifest["preferences"]["create_portal"] = False
9209
- manifest["preferences"]["create_navigation"] = False
9210
- manifest["entities"] = [entity]
9211
- assembly.set_manifest(manifest)
9212
- artifacts = default_artifacts()
9213
- artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
9214
- assembly.set_artifacts(artifacts)
9215
- flow_stage_spec = {
9216
- "solution_name": manifest["solution_name"],
9217
- "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
9218
- }
9219
- assembly.set_stage_spec("app_flow", flow_stage_spec)
9220
- stage = self.solutions.solution_build_flow(
9221
- profile=profile,
9222
- mode="apply",
9223
- build_id=build_id,
9224
- flow_spec=flow_stage_spec,
9225
- publish=False,
9226
- run_label=None,
9227
- repair_patch={},
9228
- )
9229
- finally:
9230
- if previous_build_home is None:
9231
- os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
9232
- if stage.get("status") != "success":
9233
- failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
9234
- failed["normalized_args"] = normalized_args
9235
- suggested_next_call = failed.get("suggested_next_call")
9236
- if not isinstance(suggested_next_call, dict):
9237
- suggested_next_call = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
9238
- elif suggested_next_call.get("tool_name") == "app_flow_plan":
9239
- arguments = suggested_next_call.get("arguments")
9240
- if not isinstance(arguments, dict):
9241
- arguments = {}
9242
- arguments.setdefault("profile", profile)
9243
- arguments.setdefault("app_key", app_key)
9244
- arguments.setdefault("mode", mode)
9245
- arguments.setdefault("nodes", public_nodes)
9246
- arguments.setdefault("transitions", transitions)
9247
- suggested_next_call["tool_name"] = "app_flow_apply"
9248
- suggested_next_call["arguments"] = arguments
9249
- failed["suggested_next_call"] = suggested_next_call
9250
- return finalize(failed)
9251
- verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
9252
- workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
9253
- current_workflow=verified_nodes,
9254
- requested_nodes=normalized_nodes,
9255
- )
9256
- branch_structure_verified = bool(verified_nodes) and _workflow_branch_structure_verified(
9257
- current_workflow=verified_nodes,
9258
- requested_nodes=normalized_nodes,
9259
- )
9260
- workflow_verified = workflow_structure_verified and branch_structure_verified
9261
- warnings: list[dict[str, Any]] = []
9262
- if verified_nodes_unavailable:
9263
- status = "partial_success"
9264
- error_code = "FLOW_READBACK_PENDING"
9265
- recoverable = True
9266
- message = "applied workflow patch; flow readback pending"
9267
- suggested_next_call = {"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}}
9268
- elif workflow_verified:
9269
- status = "success"
9270
- error_code = None
9271
- recoverable = False
9272
- message = "applied workflow patch"
9273
- suggested_next_call = None
9274
- else:
9275
- status = "partial_success"
9276
- error_code = "FLOW_BRANCH_VERIFICATION_FAILED" if workflow_structure_verified and not branch_structure_verified else "FLOW_VERIFICATION_FAILED"
9277
- recoverable = True
9278
- message = (
9279
- "applied workflow patch; branch or condition structure did not fully verify"
9280
- if workflow_structure_verified and not branch_structure_verified
9281
- else "applied workflow patch; flow readback did not confirm the requested workflow"
9282
- )
9283
- suggested_next_call = {"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}}
9284
- if not branch_structure_verified:
9285
- warnings.append(_warning("WORKFLOW_BRANCH_STRUCTURE_UNVERIFIED", "branch or condition structure was written, but MCP could not fully verify downstream lane structure"))
9286
- response = {
9287
- "status": status,
9288
- "error_code": error_code,
9289
- "recoverable": recoverable,
9290
- "message": message,
9291
- "normalized_args": normalized_args,
9292
- "missing_fields": [],
9293
- "allowed_values": {"modes": ["replace"]},
9294
- "details": {},
9295
- "request_id": None,
9296
- "suggested_next_call": suggested_next_call,
9297
- "noop": False,
9298
- "warnings": warnings,
9299
- "verification": {
9300
- "workflow_verified": workflow_verified,
9301
- "workflow_structure_verified": workflow_structure_verified,
9302
- "branch_structure_verified": branch_structure_verified,
9303
- "workflow_read_unavailable": verified_nodes_unavailable,
9304
- },
9305
- "app_key": app_key,
9306
- "app_name": app_name,
9307
- "flow_diff": {"mode": "replace", "node_count": desired_node_count},
9308
- "verified": workflow_verified,
9309
- "write_executed": True,
9310
- "write_succeeded": True,
9311
- "safe_to_retry": False,
9312
- }
9313
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9314
9015
 
9315
9016
  def app_views_apply(
9316
9017
  self,
@@ -12492,7 +12193,7 @@ class AiBuilderFacade:
12492
12193
  result = legacy_normalized or normalized_views
12493
12194
  return (result, False, None) if include_error else (result, False)
12494
12195
 
12495
- def _load_workflow_result(
12196
+ def _load_workflow_spec_snapshot(
12496
12197
  self,
12497
12198
  *,
12498
12199
  profile: str,
@@ -12502,24 +12203,34 @@ class AiBuilderFacade:
12502
12203
  include_error: bool = False,
12503
12204
  ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
12504
12205
  try:
12505
- 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
+ )
12506
12214
  except (QingflowApiError, RuntimeError) as error:
12507
12215
  api_error = _coerce_api_error(error)
12508
12216
  if tolerate_404 and (
12509
12217
  api_error.http_status == 404
12510
12218
  or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
12511
12219
  ):
12512
- return ([], True, api_error) if include_error else ([], True)
12220
+ return {}, True
12513
12221
  raise
12514
- result = workflow.get("result")
12515
- return (result, False, None) if include_error else (result, False)
12222
+ return payload if isinstance(payload, dict) else {}, False
12516
12223
 
12517
12224
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
12518
12225
  state = self._load_base_schema_state(profile=profile, app_key=app_key)
12519
12226
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12520
- 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
+ )
12521
12232
  state["views"] = views
12522
- state["workflow"] = workflow
12233
+ state["workflow_spec"] = workflow_spec
12523
12234
  state["views_unavailable"] = views_unavailable
12524
12235
  state["workflow_unavailable"] = workflow_unavailable
12525
12236
  return state
@@ -20501,30 +20212,6 @@ def _build_layout_preset_sections(*, preset: LayoutPreset, field_names: list[str
20501
20212
  return sections
20502
20213
 
20503
20214
 
20504
- def _build_flow_preset(preset: FlowPreset) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
20505
- if preset == FlowPreset.basic_fill_then_approve:
20506
- nodes = [
20507
- {"id": "start", "type": "start", "name": "发起"},
20508
- {"id": "fill_1", "type": "fill", "name": "补充信息"},
20509
- {"id": "approve_1", "type": "approve", "name": "审批"},
20510
- {"id": "end", "type": "end", "name": "结束"},
20511
- ]
20512
- transitions = [
20513
- {"from": "start", "to": "fill_1"},
20514
- {"from": "fill_1", "to": "approve_1"},
20515
- {"from": "approve_1", "to": "end"},
20516
- ]
20517
- return nodes, transitions
20518
- nodes = [
20519
- {"id": "start", "type": "start", "name": "发起"},
20520
- {"id": "approve_1", "type": "approve", "name": "审批"},
20521
- {"id": "end", "type": "end", "name": "结束"},
20522
- ]
20523
- transitions = [
20524
- {"from": "start", "to": "approve_1"},
20525
- {"from": "approve_1", "to": "end"},
20526
- ]
20527
- return nodes, transitions
20528
20215
 
20529
20216
 
20530
20217
  def _merge_flow_graph(
@@ -22379,144 +22066,35 @@ def _apply_row_widths(row: list[dict[str, Any]]) -> None:
22379
22066
  question["queWidth"] = width + (1 if index < remainder else 0)
22380
22067
 
22381
22068
 
22382
- def _summarize_workflow_nodes(result: Any) -> list[dict[str, Any]]:
22383
- nodes = []
22384
- if isinstance(result, dict):
22385
- iterable = result.values()
22386
- elif isinstance(result, list):
22387
- iterable = result
22388
- else:
22389
- iterable = []
22390
- for item in iterable:
22391
- if not isinstance(item, dict):
22392
- continue
22393
- node_id = _coerce_positive_int(item.get("auditNodeId"))
22394
- name = item.get("auditNodeName")
22395
- if node_id is None or not name:
22396
- continue
22397
- nodes.append({"id": node_id, "name": name, "type": item.get("type"), "deal_type": item.get("dealType")})
22398
- return nodes
22399
-
22400
-
22401
- def _workflow_node_iterable(result: Any) -> list[dict[str, Any]]:
22402
- if isinstance(result, dict):
22403
- iterable = result.values()
22404
- elif isinstance(result, list):
22405
- iterable = result
22406
- else:
22407
- iterable = []
22408
- return [item for item in iterable if isinstance(item, dict)]
22409
-
22410
-
22411
- def _workflow_condition_matrix_value(node: dict[str, Any]) -> list[Any]:
22412
- config = node.get("config")
22413
- if isinstance(config, dict):
22414
- matrix = config.get("autoJudges")
22415
- if isinstance(matrix, list):
22416
- return deepcopy(matrix)
22417
- matrix = config.get("conditionFormatMatrix")
22418
- if isinstance(matrix, list):
22419
- return deepcopy(matrix)
22420
- matrix = node.get("autoJudges")
22421
- if isinstance(matrix, list):
22422
- return deepcopy(matrix)
22423
- matrix = node.get("conditionFormatMatrix")
22424
- if isinstance(matrix, list):
22425
- 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)]
22426
22080
  return []
22427
22081
 
22428
22082
 
22429
- def _workflow_branch_structure_verified(*, current_workflow: Any, requested_nodes: list[dict[str, Any]]) -> bool:
22430
- requested_branch_like = [
22431
- node
22432
- for node in requested_nodes
22433
- if str(node.get("type") or "").strip().lower() in {"branch", "condition"}
22434
- ]
22435
- if not requested_branch_like:
22436
- return True
22437
- actual_nodes = _workflow_node_iterable(current_workflow)
22438
- actual_branch_like = [
22439
- node
22440
- for node in actual_nodes
22441
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("dealType")) in {"branch", "condition"}
22442
- ]
22443
- if len(actual_branch_like) < len(requested_branch_like):
22444
- return False
22445
- actual_condition_nodes = [
22446
- node
22447
- for node in actual_nodes
22448
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("dealType")) == "condition"
22449
- ]
22450
- for requested in requested_branch_like:
22451
- if str(requested.get("type") or "").strip().lower() != "condition":
22452
- continue
22453
- expected_matrix = _workflow_condition_matrix_value(requested)
22454
- 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:
22455
22089
  continue
22456
- requested_name = str(requested.get("name") or "").strip()
22457
- match = next(
22458
- (
22459
- node
22460
- for node in actual_condition_nodes
22461
- if str(node.get("auditNodeName") or node.get("nodeName") or "").strip() == requested_name
22462
- ),
22463
- None,
22464
- )
22465
- if match is None or not _workflow_condition_matrix_value(match):
22466
- return False
22467
- return True
22468
-
22469
-
22470
- def _workflow_nodes_semantically_equal(*, current_workflow: Any, requested_nodes: list[dict[str, Any]]) -> bool:
22471
- current_nodes = _summarize_workflow_nodes(current_workflow)
22472
- current_effective = [
22473
- node
22474
- for node in current_nodes
22475
- if _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")) not in {"start", "end"}
22476
- ]
22477
- requested_effective = [
22478
- node for node in requested_nodes if str(node.get("type") or "") not in {"start", "end"}
22479
- ]
22480
- if not current_effective or not requested_effective or len(current_effective) != len(requested_effective):
22481
- return False
22482
- current_signatures = sorted(
22483
- (
22484
- _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")),
22485
- str(node.get("name") or "").strip(),
22486
- )
22487
- for node in current_effective
22488
- )
22489
- requested_signatures = sorted(
22490
- (
22491
- str(node.get("type") or "").strip().lower(),
22492
- 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
+ }
22493
22096
  )
22494
- for node in requested_effective
22495
- )
22496
- return current_signatures == requested_signatures
22497
-
22498
-
22499
- def _normalize_existing_workflow_node_type(raw_type: Any, deal_type: Any) -> str:
22500
- normalized = "" if raw_type is None else str(raw_type).strip().lower()
22501
- if normalized in {"audit", "approve"}:
22502
- return "approve"
22503
- if normalized in {"fill", "copy", "branch", "condition", "webhook", "start", "end"}:
22504
- return normalized
22505
- if normalized in {"1"}:
22506
- return "branch"
22507
- if normalized in {"2"}:
22508
- return "condition"
22509
- if normalized in {"3"} or deal_type == 10:
22510
- return "webhook"
22511
- if normalized in {"0"} and deal_type == 0:
22512
- return "approve"
22513
- if normalized in {"0"} and deal_type == 1:
22514
- return "fill"
22515
- if normalized in {"0"} and deal_type == 2:
22516
- return "copy"
22517
- if normalized in {"0"} or deal_type == 3:
22518
- return "start"
22519
- return normalized or "unknown"
22097
+ return summaries
22520
22098
 
22521
22099
 
22522
22100
  def _summarize_views(result: Any) -> list[dict[str, Any]]:
@@ -23714,98 +23292,6 @@ def _field_id_for_name(fields: list[dict[str, Any]], name: str) -> str:
23714
23292
  raise KeyError(name)
23715
23293
 
23716
23294
 
23717
- def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: list[dict[str, Any]]) -> JSONObject:
23718
- node_map = {str(node.get("id") or ""): node for node in nodes if isinstance(node, dict) and node.get("id")}
23719
- if not node_map:
23720
- return _failed("INVALID_FLOW", "nodes must be a non-empty list", suggested_next_call=None)
23721
- start_nodes = [node_id for node_id, node in node_map.items() if node.get("type") == "start"]
23722
- if len(start_nodes) != 1:
23723
- return _failed("INVALID_FLOW", "flow must contain exactly one start node", suggested_next_call=None)
23724
- inbound: dict[str, list[str]] = {node_id: [] for node_id in node_map}
23725
- outbound: dict[str, list[str]] = {node_id: [] for node_id in node_map}
23726
- for transition in transitions:
23727
- if not isinstance(transition, dict):
23728
- continue
23729
- source = str(transition.get("from") or "")
23730
- target = str(transition.get("to") or "")
23731
- if source not in node_map or target not in node_map:
23732
- return _failed("INVALID_FLOW_EDGE", "transition references unknown node", details={"transition": transition}, suggested_next_call=None)
23733
- outbound[source].append(target)
23734
- inbound[target].append(source)
23735
- branch_lane_nodes: dict[str, dict[str, Any]] = {}
23736
- for node_id, node in node_map.items():
23737
- if str(node.get("type") or "") != "condition":
23738
- continue
23739
- if len(inbound[node_id]) != 1:
23740
- return _failed(
23741
- "INVALID_FLOW_EDGE",
23742
- f"condition lane '{node_id}' must have exactly one inbound transition",
23743
- details={"node_id": node_id, "inbound": inbound[node_id]},
23744
- suggested_next_call=None,
23745
- )
23746
- parent_id = inbound[node_id][0]
23747
- if node_map[parent_id].get("type") != "branch":
23748
- continue
23749
- branch_lane_nodes[node_id] = {
23750
- "branch_parent_id": parent_id,
23751
- "branch_index": outbound[parent_id].index(node_id) + 1,
23752
- }
23753
- internal_nodes = []
23754
- for node_id, node in node_map.items():
23755
- node_type = str(node.get("type") or "")
23756
- if node_type == "end":
23757
- continue
23758
- if node_type != "start" and len(inbound[node_id]) != 1:
23759
- return _failed(
23760
- "INVALID_FLOW_EDGE",
23761
- f"node '{node_id}' must have exactly one inbound transition",
23762
- details={"node_id": node_id, "inbound": inbound[node_id]},
23763
- suggested_next_call=None,
23764
- )
23765
- config_payload = deepcopy(node.get("config") or {}) if isinstance(node.get("config"), dict) else {}
23766
- assignees = node.get("assignees") or {}
23767
- if node_id in branch_lane_nodes:
23768
- lane_meta = branch_lane_nodes[node_id]
23769
- config_payload["__lane_only__"] = True
23770
- payload = {
23771
- "node_id": f"__branch_lane__{lane_meta['branch_parent_id']}__{lane_meta['branch_index']}",
23772
- "name": node.get("name") or node_id,
23773
- "node_type": "condition",
23774
- "parent_node_id": lane_meta["branch_parent_id"],
23775
- "branch_parent_id": lane_meta["branch_parent_id"],
23776
- "branch_index": lane_meta["branch_index"],
23777
- }
23778
- else:
23779
- payload = {
23780
- "node_id": node_id,
23781
- "name": node.get("name") or node_id,
23782
- "node_type": "audit" if node_type == "approve" else node_type,
23783
- }
23784
- if node_type != "start":
23785
- parent_id = inbound[node_id][0]
23786
- lane_parent = branch_lane_nodes.get(parent_id)
23787
- if lane_parent is not None:
23788
- payload["parent_node_id"] = lane_parent["branch_parent_id"]
23789
- payload["branch_parent_id"] = lane_parent["branch_parent_id"]
23790
- payload["branch_index"] = lane_parent["branch_index"]
23791
- else:
23792
- payload["parent_node_id"] = parent_id
23793
- if node_map[parent_id].get("type") == "branch":
23794
- payload["branch_parent_id"] = parent_id
23795
- payload["branch_index"] = outbound[parent_id].index(node_id) + 1
23796
- permissions = node.get("permissions") or {}
23797
- editable_que_ids = permissions.get("editable_que_ids") or []
23798
- if editable_que_ids:
23799
- config_payload["editableQueIds"] = editable_que_ids
23800
- if config_payload:
23801
- payload["config"] = config_payload
23802
- if assignees:
23803
- payload["assignees"] = deepcopy(assignees)
23804
- internal_nodes.append(payload)
23805
- return {
23806
- "status": "success",
23807
- "workflow": {"enabled": True, "nodes": internal_nodes},
23808
- }
23809
23295
 
23810
23296
 
23811
23297
  def _build_flow_condition_matrix(
@@ -25911,92 +25397,6 @@ def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
25911
25397
  return None
25912
25398
 
25913
25399
 
25914
- def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: str, entity: dict[str, Any]) -> JSONObject:
25915
- stage_error_code = str(stage.get("error_code") or "FLOW_APPLY_FAILED")
25916
- detail_text = _extract_stage_failure_text(stage)
25917
- request_id = stage.get("request_id")
25918
- backend_code = stage.get("backend_code")
25919
- http_status = stage.get("http_status")
25920
- public_stage_result = _public_stage_result(stage)
25921
- if request_id is None and isinstance(public_stage_result.get("errors"), list) and public_stage_result["errors"]:
25922
- first_error = public_stage_result["errors"][0]
25923
- if isinstance(first_error, dict):
25924
- request_id = first_error.get("request_id")
25925
- backend_code = first_error.get("backend_code")
25926
- http_status = first_error.get("http_status")
25927
- lowered_detail = detail_text.lower()
25928
- if "must declare status field" in detail_text:
25929
- return _failed(
25930
- "STATUS_FIELD_REQUIRED",
25931
- detail_text,
25932
- details={
25933
- "app_key": app_key,
25934
- "entity_id": entity.get("entity_id"),
25935
- "existing_fields": entity.get("fields") or [],
25936
- "stage_result": public_stage_result,
25937
- },
25938
- suggested_next_call={
25939
- "tool_name": "app_schema_apply",
25940
- "arguments": {
25941
- "profile": profile,
25942
- "app_key": app_key,
25943
- "create_if_missing": False,
25944
- "add_fields": [
25945
- {
25946
- "name": "状态",
25947
- "type": "single_select",
25948
- "options": ["草稿", "进行中", "已完成"],
25949
- "required": True,
25950
- }
25951
- ],
25952
- "update_fields": [],
25953
- "remove_fields": [],
25954
- },
25955
- },
25956
- request_id=request_id,
25957
- backend_code=backend_code,
25958
- http_status=http_status,
25959
- )
25960
- if (
25961
- "run solution_build_app first" in lowered_detail
25962
- or "run solution_build_app_flow first" in lowered_detail
25963
- or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
25964
- ):
25965
- return _failed(
25966
- "FLOW_STAGE_CONTEXT_MISSING",
25967
- "workflow apply lost the app context required by the internal flow builder",
25968
- details={
25969
- "app_key": app_key,
25970
- "entity_id": entity.get("entity_id"),
25971
- "existing_fields": entity.get("fields") or [],
25972
- "internal_detail": detail_text,
25973
- "stage_result": public_stage_result,
25974
- },
25975
- suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
25976
- request_id=request_id,
25977
- backend_code=backend_code,
25978
- http_status=http_status,
25979
- )
25980
- message = detail_text or "failed to apply workflow patch"
25981
- details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
25982
- public_http_status = http_status
25983
- if http_status == 404:
25984
- message = "workflow write route is unavailable for this app in the current route"
25985
- details["transport_error"] = {
25986
- "http_status": http_status,
25987
- "backend_code": backend_code,
25988
- "category": "http",
25989
- }
25990
- public_http_status = None
25991
- return _failed(
25992
- stage_error_code,
25993
- message,
25994
- details=details,
25995
- suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
25996
- request_id=request_id,
25997
- backend_code=backend_code,
25998
- http_status=public_http_status,
25999
- )
26000
25400
 
26001
25401
 
26002
25402
  def _extract_stage_failure_text(stage: JSONObject) -> str: