@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,4054 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from ..config import DEFAULT_PROFILE
12
+ from ..errors import QingflowApiError
13
+ from ..list_type_labels import get_record_list_type_label
14
+ from ..solution.build_assembly_store import BuildAssemblyStore, default_manifest
15
+ from ..solution.compiler import CompiledSolution, ExecutionPlan, ExecutionStep, build_execution_plan, compile_solution
16
+ from ..solution.design_session import DesignStage, evaluate_design_session, finalize_design_session, merge_design_payload
17
+ from ..solution.design_store import DesignSessionStore
18
+ from ..solution.executor import SolutionExecutor
19
+ from ..solution.normalizer import normalize_solution_spec
20
+ from ..solution.requirements_builder import GeneratedAppBuild, RequirementsBuildError, build_app_spec_from_requirements
21
+ from ..solution.run_store import RunArtifactStore, fingerprint_payload
22
+ from ..solution.spec_models import (
23
+ AnalyticsPortalBuildSpec,
24
+ AppBuildSpec,
25
+ AppFlowBuildSpec,
26
+ FlowBuildSpec,
27
+ BuildManifest,
28
+ NavigationBuildSpec,
29
+ SolutionSpec,
30
+ ViewsBuildSpec,
31
+ )
32
+ from .app_tools import AppTools
33
+ from .base import ToolBase, tool_cn_name
34
+ from .navigation_tools import NavigationTools
35
+ from .package_tools import PackageTools
36
+ from .portal_tools import PortalTools
37
+ from .qingbi_report_tools import QingbiReportTools
38
+ from .record_tools import RecordTools
39
+ from .role_tools import RoleTools
40
+ from .view_tools import ViewTools
41
+ from .workflow_tools import WorkflowTools
42
+ from .workspace_tools import WorkspaceTools
43
+
44
+ STAGED_BUILD_MODES = {"preflight", "plan", "apply", "repair"}
45
+ STAGED_BUILD_MODE_ALIASES = {
46
+ "create": "plan",
47
+ "new": "plan",
48
+ "start": "plan",
49
+ "draft": "plan",
50
+ "update": "plan",
51
+ "modify": "plan",
52
+ "preview": "preflight",
53
+ "prepare": "preflight",
54
+ "run": "apply",
55
+ "execute": "apply",
56
+ "fix": "repair",
57
+ "resume": "repair",
58
+ "retry": "repair",
59
+ }
60
+ STAGE_ORDER = ["app_flow", "views", "analytics_portal", "navigation"]
61
+ STAGE_TOOL_NAMES = {
62
+ "app_flow": "solution_build_app_flow",
63
+ "views": "solution_build_views",
64
+ "analytics_portal": "solution_build_analytics_portal",
65
+ "navigation": "solution_build_navigation",
66
+ }
67
+ SOLUTION_SCHEMA_STAGES = {"app", "app_update", "flow", "views", "analytics_portal", "navigation", "app_flow", "all"}
68
+ SOLUTION_SCHEMA_INTENTS = {"minimal", "full", "demo"}
69
+ SOLUTION_SCHEMA_STAGE_ALIASES = {
70
+ "all": "all",
71
+ "solution": "all",
72
+ "full_solution": "all",
73
+ "app": "app",
74
+ "application": "app",
75
+ "form": "app",
76
+ "schema": "app",
77
+ "app_spec": "app",
78
+ "app_update": "app_update",
79
+ "update_app": "app_update",
80
+ "update": "app_update",
81
+ "更新应用": "app_update",
82
+ "更新表单": "app_update",
83
+ "更新schema": "app_update",
84
+ "应用": "app",
85
+ "表单": "app",
86
+ "flow": "flow",
87
+ "workflow": "flow",
88
+ "流程": "flow",
89
+ "flow_spec": "flow",
90
+ "views": "views",
91
+ "view": "views",
92
+ "视图": "views",
93
+ "analytics_portal": "analytics_portal",
94
+ "analytics": "analytics_portal",
95
+ "portal": "analytics_portal",
96
+ "report": "analytics_portal",
97
+ "dashboard": "analytics_portal",
98
+ "报表": "analytics_portal",
99
+ "门户": "analytics_portal",
100
+ "navigation": "navigation",
101
+ "nav": "navigation",
102
+ "menu": "navigation",
103
+ "导航": "navigation",
104
+ "app_flow": "app_flow",
105
+ "应用流程": "app_flow",
106
+ }
107
+ SOLUTION_SCHEMA_INTENT_ALIASES = {
108
+ "minimal": "minimal",
109
+ "min": "minimal",
110
+ "最小": "minimal",
111
+ "最简": "minimal",
112
+ "骨架": "minimal",
113
+ "基础": "minimal",
114
+ "full": "full",
115
+ "complete": "full",
116
+ "完整": "full",
117
+ "全字段": "full",
118
+ "完整布局": "full",
119
+ "all_fields": "full",
120
+ "demo": "demo",
121
+ "example": "demo",
122
+ "sample": "demo",
123
+ "示例": "demo",
124
+ "演示": "demo",
125
+ }
126
+
127
+
128
+ class SolutionTools(ToolBase):
129
+ """解决方案工具(中文名:方案会话与安装执行)。
130
+
131
+ 类型:解决方案编排工具。
132
+ 主要职责:
133
+ 1. 管理设计会话与运行会话;
134
+ 2. 安装与应用 SolutionSpec;
135
+ 3. 输出安装结果、阶段状态与故障信息。
136
+ """
137
+
138
+ def register(self, mcp: FastMCP) -> None:
139
+ """注册当前工具到 MCP 服务。"""
140
+ @mcp.tool()
141
+ def solution_design_session(
142
+ action: str = "get",
143
+ session_id: str = "",
144
+ stage: str | None = None,
145
+ design_patch: dict[str, Any] | None = None,
146
+ metadata: dict[str, Any] | None = None,
147
+ ) -> dict[str, Any]:
148
+ return self.solution_design_session(
149
+ action=action,
150
+ session_id=session_id,
151
+ stage=stage,
152
+ design_patch=design_patch or {},
153
+ metadata=metadata or {},
154
+ )
155
+
156
+ @mcp.tool()
157
+ def solution_build_all(
158
+ profile: str = DEFAULT_PROFILE,
159
+ mode: str = "plan",
160
+ build_id: str = "",
161
+ solution_spec: dict[str, Any] | None = None,
162
+ publish: bool = True,
163
+ run_label: str | None = None,
164
+ target: dict[str, Any] | None = None,
165
+ repair_patch: dict[str, Any] | None = None,
166
+ verify: bool = False,
167
+ ) -> dict[str, Any]:
168
+ return self.solution_build_all(
169
+ profile=profile,
170
+ mode=mode,
171
+ build_id=build_id,
172
+ solution_spec=solution_spec or {},
173
+ publish=publish,
174
+ run_label=run_label,
175
+ target=target or {},
176
+ repair_patch=repair_patch or {},
177
+ verify=verify,
178
+ )
179
+
180
+ @mcp.tool()
181
+ def solution_build_app(
182
+ profile: str = DEFAULT_PROFILE,
183
+ mode: str = "plan",
184
+ build_id: str = "",
185
+ app_spec: dict[str, Any] | None = None,
186
+ app_key: str = "",
187
+ package_tag_id: int = 0,
188
+ update_only: bool = False,
189
+ publish: bool = True,
190
+ run_label: str | None = None,
191
+ target: dict[str, Any] | None = None,
192
+ repair_patch: dict[str, Any] | None = None,
193
+ ) -> dict[str, Any]:
194
+ return self.solution_build_app(
195
+ profile=profile,
196
+ mode=mode,
197
+ build_id=build_id,
198
+ app_spec=app_spec or {},
199
+ app_key=app_key,
200
+ package_tag_id=package_tag_id,
201
+ update_only=update_only,
202
+ publish=publish,
203
+ run_label=run_label,
204
+ target=target or {},
205
+ repair_patch=repair_patch or {},
206
+ )
207
+
208
+ @mcp.tool()
209
+ def solution_build_app_from_requirements(
210
+ profile: str = DEFAULT_PROFILE,
211
+ mode: str = "plan",
212
+ build_id: str = "",
213
+ title: str = "",
214
+ requirement_text: str = "",
215
+ package_tag_id: int = 0,
216
+ package_name: str = "",
217
+ app_key: str = "",
218
+ update_only: bool = False,
219
+ layout_style: str = "auto",
220
+ publish: bool = True,
221
+ run_label: str | None = None,
222
+ include_generated_spec: bool = False,
223
+ ) -> dict[str, Any]:
224
+ return self.solution_build_app_from_requirements(
225
+ profile=profile,
226
+ mode=mode,
227
+ build_id=build_id,
228
+ title=title,
229
+ requirement_text=requirement_text,
230
+ package_tag_id=package_tag_id,
231
+ package_name=package_name,
232
+ app_key=app_key,
233
+ update_only=update_only,
234
+ layout_style=layout_style,
235
+ publish=publish,
236
+ run_label=run_label,
237
+ include_generated_spec=include_generated_spec,
238
+ )
239
+
240
+ @mcp.tool()
241
+ def solution_build_flow(
242
+ profile: str = DEFAULT_PROFILE,
243
+ mode: str = "plan",
244
+ build_id: str = "",
245
+ flow_spec: dict[str, Any] | None = None,
246
+ publish: bool = True,
247
+ run_label: str | None = None,
248
+ repair_patch: dict[str, Any] | None = None,
249
+ ) -> dict[str, Any]:
250
+ return self.solution_build_flow(
251
+ profile=profile,
252
+ mode=mode,
253
+ build_id=build_id,
254
+ flow_spec=flow_spec or {},
255
+ publish=publish,
256
+ run_label=run_label,
257
+ repair_patch=repair_patch or {},
258
+ )
259
+
260
+ @mcp.tool()
261
+ def solution_build_app_flow(
262
+ profile: str = DEFAULT_PROFILE,
263
+ mode: str = "plan",
264
+ build_id: str = "",
265
+ app_flow_spec: dict[str, Any] | None = None,
266
+ publish: bool = True,
267
+ run_label: str | None = None,
268
+ target: dict[str, Any] | None = None,
269
+ repair_patch: dict[str, Any] | None = None,
270
+ ) -> dict[str, Any]:
271
+ return self.solution_build_app_flow(
272
+ profile=profile,
273
+ mode=mode,
274
+ build_id=build_id,
275
+ app_flow_spec=app_flow_spec or {},
276
+ publish=publish,
277
+ run_label=run_label,
278
+ target=target or {},
279
+ repair_patch=repair_patch or {},
280
+ )
281
+
282
+ @mcp.tool()
283
+ def solution_schema_example(
284
+ stage: str = "app",
285
+ intent: str = "minimal",
286
+ include_examples: bool = False,
287
+ include_selected_example: bool = False,
288
+ ) -> dict[str, Any]:
289
+ return self.solution_schema_example(
290
+ stage=stage,
291
+ intent=intent,
292
+ include_examples=include_examples,
293
+ include_selected_example=include_selected_example,
294
+ )
295
+
296
+ @mcp.tool()
297
+ def solution_build_views(
298
+ profile: str = DEFAULT_PROFILE,
299
+ mode: str = "plan",
300
+ build_id: str = "",
301
+ views_spec: dict[str, Any] | None = None,
302
+ publish: bool = True,
303
+ run_label: str | None = None,
304
+ repair_patch: dict[str, Any] | None = None,
305
+ ) -> dict[str, Any]:
306
+ return self.solution_build_views(
307
+ profile=profile,
308
+ mode=mode,
309
+ build_id=build_id,
310
+ views_spec=views_spec or {},
311
+ publish=publish,
312
+ run_label=run_label,
313
+ repair_patch=repair_patch or {},
314
+ )
315
+
316
+ @mcp.tool()
317
+ def solution_build_analytics_portal(
318
+ profile: str = DEFAULT_PROFILE,
319
+ mode: str = "plan",
320
+ build_id: str = "",
321
+ analytics_portal_spec: dict[str, Any] | None = None,
322
+ publish: bool = True,
323
+ run_label: str | None = None,
324
+ repair_patch: dict[str, Any] | None = None,
325
+ ) -> dict[str, Any]:
326
+ return self.solution_build_analytics_portal(
327
+ profile=profile,
328
+ mode=mode,
329
+ build_id=build_id,
330
+ analytics_portal_spec=analytics_portal_spec or {},
331
+ publish=publish,
332
+ run_label=run_label,
333
+ repair_patch=repair_patch or {},
334
+ )
335
+
336
+ @mcp.tool()
337
+ def solution_build_navigation(
338
+ profile: str = DEFAULT_PROFILE,
339
+ mode: str = "plan",
340
+ build_id: str = "",
341
+ navigation_spec: dict[str, Any] | None = None,
342
+ publish: bool = True,
343
+ run_label: str | None = None,
344
+ repair_patch: dict[str, Any] | None = None,
345
+ ) -> dict[str, Any]:
346
+ return self.solution_build_navigation(
347
+ profile=profile,
348
+ mode=mode,
349
+ build_id=build_id,
350
+ navigation_spec=navigation_spec or {},
351
+ publish=publish,
352
+ run_label=run_label,
353
+ repair_patch=repair_patch or {},
354
+ )
355
+
356
+ @mcp.tool()
357
+ def solution_build_status(build_id: str = "") -> dict[str, Any]:
358
+ return self.solution_build_status(build_id=build_id)
359
+
360
+ @tool_cn_name("方案设计会话")
361
+ def solution_design_session(
362
+ self,
363
+ *,
364
+ action: str,
365
+ session_id: str,
366
+ stage: str | None,
367
+ design_patch: dict[str, Any],
368
+ metadata: dict[str, Any],
369
+ ) -> dict[str, Any]:
370
+ """执行方案相关逻辑。"""
371
+ try:
372
+ if action == "start" and not session_id:
373
+ session_id = _generate_session_id(metadata=metadata, stage=stage)
374
+ if action not in {"start", "get", "submit_stage", "finalize"}:
375
+ return self._design_failure_response(session_id, action, "config", "action must be one of: start, get, submit_stage, finalize")
376
+ if not session_id:
377
+ return self._design_failure_response(session_id, action, "config", "session_id is required")
378
+ store = DesignSessionStore.open(session_id=session_id, metadata=metadata)
379
+ if metadata:
380
+ store.set_metadata(metadata)
381
+
382
+ if action == "start":
383
+ evaluation = evaluate_design_session(store.data.get("stage_payloads", {}))
384
+ store.update_progress(
385
+ status=evaluation["status"],
386
+ current_stage=evaluation["current_stage"],
387
+ stage_results=evaluation["stage_results"],
388
+ merged_design_spec=evaluation["merged_design_spec"],
389
+ )
390
+ return self._design_summary(store)
391
+
392
+ if action == "get":
393
+ return self._design_summary(store)
394
+
395
+ if action == "submit_stage":
396
+ if stage not in {item.value for item in DesignStage if item != DesignStage.finalize}:
397
+ return self._design_failure_response(session_id, action, "config", "stage must be one of: discover, design, experience")
398
+ if not isinstance(design_patch, dict) or not design_patch:
399
+ return self._design_failure_response(session_id, action, "config", "design_patch must be a non-empty object")
400
+ existing_payload = store.get_stage_payload(stage)
401
+ merged_payload = merge_design_payload(existing_payload, design_patch)
402
+ store.set_stage_payload(stage, merged_payload)
403
+ evaluation = evaluate_design_session(store.data.get("stage_payloads", {}))
404
+ store.update_progress(
405
+ status=evaluation["status"],
406
+ current_stage=evaluation["current_stage"],
407
+ stage_results=evaluation["stage_results"],
408
+ merged_design_spec=evaluation["merged_design_spec"],
409
+ )
410
+ return self._design_summary(store)
411
+
412
+ finalized = finalize_design_session(store.data.get("stage_payloads", {}))
413
+ store.update_progress(
414
+ status=finalized["status"],
415
+ current_stage=finalized["current_stage"],
416
+ stage_results=finalized["stage_results"],
417
+ merged_design_spec=finalized["merged_design_spec"],
418
+ )
419
+ store.mark_finalized(
420
+ normalized_solution_spec=finalized["normalized_solution_spec"],
421
+ execution_plan=finalized["execution_plan"],
422
+ )
423
+ return self._design_summary(store)
424
+ except ValidationError as exc:
425
+ return self._design_failure_response(session_id, action, "validation", exc.errors())
426
+ except ValueError as exc:
427
+ return self._design_failure_response(session_id, action, "config", str(exc))
428
+ except Exception as exc: # noqa: BLE001
429
+ return self._design_failure_response(session_id, action, "runtime", str(exc))
430
+
431
+ @tool_cn_name("方案启动搭建")
432
+ def solution_bootstrap(
433
+ self,
434
+ *,
435
+ profile: str,
436
+ mode: str,
437
+ solution_spec: dict[str, Any],
438
+ idempotency_key: str,
439
+ publish: bool,
440
+ run_label: str | None,
441
+ target: dict[str, Any],
442
+ repair_patch: dict[str, Any],
443
+ ) -> dict[str, Any]:
444
+ """执行方案相关逻辑。"""
445
+ mode = _normalize_staged_build_mode(mode)
446
+ if mode not in {"preflight", "plan", "apply", "repair"}:
447
+ return self._failure_response(mode, idempotency_key, "config", "mode must be one of: preflight, plan, apply, repair")
448
+ if not idempotency_key:
449
+ return self._failure_response(mode, idempotency_key, "config", "idempotency_key is required")
450
+ if not isinstance(solution_spec, dict) or not solution_spec:
451
+ return self._failure_response(mode, idempotency_key, "config", "solution_spec must be a non-empty object")
452
+ if mode != "repair" and repair_patch:
453
+ return self._failure_response(mode, idempotency_key, "config", "repair_patch is only supported in repair mode")
454
+
455
+ def runner() -> dict[str, Any]:
456
+ try:
457
+ base_spec_payload = deepcopy(solution_spec)
458
+ if mode == "repair" and repair_patch:
459
+ base_spec_payload = deep_merge(base_spec_payload, repair_patch)
460
+ parsed = SolutionSpec.model_validate(base_spec_payload)
461
+ normalized = normalize_solution_spec(parsed)
462
+ compiled = compile_solution(normalized)
463
+ package_tag_id = _resolve_target_package_tag_id(target)
464
+ if package_tag_id is not None:
465
+ compiled = _bind_existing_package_target(compiled, package_tag_id)
466
+ request_fingerprint = fingerprint_payload(
467
+ {
468
+ "mode": "apply" if mode == "repair" else mode,
469
+ "publish": publish,
470
+ "solution_spec": normalized.model_dump(mode="json"),
471
+ "target": target,
472
+ }
473
+ )
474
+ if mode in {"preflight", "plan"}:
475
+ store = BuildAssemblyStore.open(build_id=idempotency_key)
476
+ store.set_manifest(normalized.model_dump(mode="json"))
477
+ return {
478
+ "build_id": idempotency_key,
479
+ "mode": mode,
480
+ "idempotency_key": idempotency_key,
481
+ "normalized_solution_spec": normalized.model_dump(mode="json"),
482
+ "execution_plan": compiled.execution_plan.as_dict(),
483
+ "artifacts": {},
484
+ "step_results": {},
485
+ "publish_results": {},
486
+ "status": "preflighted" if mode == "preflight" else "planned",
487
+ "errors": [],
488
+ "run_path": None,
489
+ "build_path": str(store.path),
490
+ "suggested_next_call": _solution_build_next_call(
491
+ tool_name="solution_build_all",
492
+ mode=mode,
493
+ build_id=idempotency_key,
494
+ ),
495
+ }
496
+
497
+ store = RunArtifactStore.open(
498
+ idempotency_key=idempotency_key,
499
+ normalized_solution_spec=normalized.model_dump(mode="json"),
500
+ request_fingerprint=request_fingerprint,
501
+ run_label=run_label,
502
+ )
503
+ if mode == "apply":
504
+ store.ensure_apply_fingerprint(request_fingerprint)
505
+ if package_tag_id is not None:
506
+ store.set_artifact("package", "tag_id", package_tag_id)
507
+ store.set_artifact("package", "reused", True)
508
+ result = self._build_executor().execute(profile=profile, compiled=compiled, store=store, publish=publish, mode=mode)
509
+ return {
510
+ "mode": mode,
511
+ "idempotency_key": idempotency_key,
512
+ "normalized_solution_spec": normalized.model_dump(mode="json"),
513
+ "execution_plan": compiled.execution_plan.as_dict(),
514
+ "artifacts": result["artifacts"],
515
+ "step_results": result["step_results"],
516
+ "publish_results": _extract_publish_results(result["step_results"]),
517
+ "status": result["status"],
518
+ "errors": result["errors"],
519
+ "run_path": result["run_path"],
520
+ }
521
+ except ValidationError as exc:
522
+ return self._failure_response(mode, idempotency_key, "validation", exc.errors())
523
+ except ValueError as exc:
524
+ return self._failure_response(mode, idempotency_key, "config", str(exc))
525
+ except Exception as exc: # noqa: BLE001
526
+ return self._failure_response(mode, idempotency_key, "runtime", str(exc))
527
+
528
+ if mode in {"preflight", "plan"}:
529
+ return runner()
530
+ return self._run(profile, lambda _session_profile, _context: runner())
531
+
532
+ @tool_cn_name("搭建应用流程")
533
+ def solution_build_app_flow(
534
+ self,
535
+ *,
536
+ profile: str,
537
+ mode: str,
538
+ build_id: str,
539
+ app_flow_spec: dict[str, Any],
540
+ publish: bool,
541
+ run_label: str | None,
542
+ target: dict[str, Any],
543
+ repair_patch: dict[str, Any],
544
+ ) -> dict[str, Any]:
545
+ """执行方案相关逻辑。"""
546
+ mode = _normalize_staged_build_mode(mode)
547
+ return self._stage_build(
548
+ profile=profile,
549
+ mode=mode,
550
+ build_id=build_id,
551
+ stage_name="app_flow",
552
+ stage_payload=app_flow_spec,
553
+ stage_model=AppFlowBuildSpec,
554
+ publish=publish,
555
+ run_label=run_label,
556
+ target=target,
557
+ repair_patch=repair_patch,
558
+ )
559
+
560
+ @tool_cn_name("搭建应用")
561
+ def solution_build_app(
562
+ self,
563
+ *,
564
+ profile: str,
565
+ mode: str,
566
+ build_id: str,
567
+ app_spec: dict[str, Any],
568
+ app_key: str = "",
569
+ package_tag_id: int = 0,
570
+ update_only: bool = False,
571
+ publish: bool,
572
+ run_label: str | None,
573
+ target: dict[str, Any],
574
+ repair_patch: dict[str, Any],
575
+ ) -> dict[str, Any]:
576
+ """执行方案相关逻辑。"""
577
+ mode = _normalize_staged_build_mode(mode)
578
+ resolved_app_spec = deepcopy(app_spec) if isinstance(app_spec, dict) else {}
579
+ if not resolved_app_spec and build_id:
580
+ resolved_app_spec = self._load_generated_app_spec(build_id) or {}
581
+ resolved_target = _merge_app_target(
582
+ target,
583
+ app_key=app_key,
584
+ package_tag_id=package_tag_id,
585
+ update_only=update_only,
586
+ )
587
+ return self._stage_build(
588
+ profile=profile,
589
+ mode=mode,
590
+ build_id=build_id,
591
+ stage_name="app_flow",
592
+ stage_payload=resolved_app_spec,
593
+ stage_model=AppBuildSpec,
594
+ publish=publish,
595
+ run_label=run_label,
596
+ target=resolved_target,
597
+ repair_patch=repair_patch,
598
+ public_stage_name="app",
599
+ tool_name="solution_build_app",
600
+ projection_name="app",
601
+ )
602
+
603
+ @tool_cn_name("从需求搭建应用")
604
+ def solution_build_app_from_requirements(
605
+ self,
606
+ *,
607
+ profile: str,
608
+ mode: str,
609
+ build_id: str,
610
+ title: str,
611
+ requirement_text: str,
612
+ package_tag_id: int,
613
+ package_name: str,
614
+ app_key: str = "",
615
+ update_only: bool = False,
616
+ layout_style: str,
617
+ publish: bool,
618
+ run_label: str | None,
619
+ include_generated_spec: bool,
620
+ ) -> dict[str, Any]:
621
+ """执行方案相关逻辑。"""
622
+ mode = _normalize_staged_build_mode(mode)
623
+ if mode in {"preflight", "plan"} and not build_id:
624
+ build_id = _generate_build_id(run_label=run_label, stage_name="app")
625
+ resolved_target = _merge_app_target(
626
+ {},
627
+ app_key=app_key,
628
+ package_tag_id=package_tag_id,
629
+ update_only=update_only,
630
+ )
631
+ resolved_package = self._resolve_builder_package_reference(
632
+ profile=profile,
633
+ package_tag_id=package_tag_id,
634
+ package_name=package_name,
635
+ )
636
+ if resolved_package.get("status") == "failed":
637
+ return resolved_package["response"]
638
+ existing_app = self._resolve_builder_existing_app_reference(
639
+ profile=profile,
640
+ app_key=app_key,
641
+ package_resolution=resolved_package,
642
+ update_only=_target_is_update_only(resolved_target),
643
+ )
644
+ if existing_app.get("status") == "failed":
645
+ return existing_app["response"]
646
+ effective_package_resolution = existing_app.get("package_resolution") if existing_app.get("package_resolution") else resolved_package
647
+ try:
648
+ generated = build_app_spec_from_requirements(
649
+ title=title,
650
+ requirement_text=requirement_text,
651
+ package_name=effective_package_resolution.get("tag_name"),
652
+ layout_style=layout_style,
653
+ )
654
+ except RequirementsBuildError as exc:
655
+ return self._requirements_failure_response(
656
+ mode=mode,
657
+ build_id=build_id,
658
+ title=title,
659
+ requirement_text=requirement_text,
660
+ package_resolution=effective_package_resolution,
661
+ app_key=app_key,
662
+ update_only=_target_is_update_only(resolved_target),
663
+ layout_style=layout_style,
664
+ publish=publish,
665
+ run_label=run_label,
666
+ detail=str(exc),
667
+ error_code=exc.error_code,
668
+ recoverable=exc.recoverable,
669
+ missing_required_fields=exc.missing_required_fields,
670
+ invalid_field_types=exc.invalid_field_types,
671
+ error_details=exc.details,
672
+ )
673
+ except ValueError as exc:
674
+ return self._requirements_failure_response(
675
+ mode=mode,
676
+ build_id=build_id,
677
+ title=title,
678
+ requirement_text=requirement_text,
679
+ package_resolution=effective_package_resolution,
680
+ app_key=app_key,
681
+ update_only=_target_is_update_only(resolved_target),
682
+ layout_style=layout_style,
683
+ publish=publish,
684
+ run_label=run_label,
685
+ detail=str(exc),
686
+ )
687
+ if build_id:
688
+ self._persist_generated_app_spec(
689
+ build_id=build_id,
690
+ generated=generated,
691
+ package_resolution=effective_package_resolution,
692
+ title=title,
693
+ requirement_text=requirement_text,
694
+ layout_style=layout_style,
695
+ )
696
+ stage_result = self.solution_build_app(
697
+ profile=profile,
698
+ mode=mode,
699
+ build_id=build_id,
700
+ app_spec=generated.app_spec,
701
+ app_key=app_key,
702
+ package_tag_id=int(effective_package_resolution.get("tag_id") or 0),
703
+ update_only=_target_is_update_only(resolved_target),
704
+ publish=publish,
705
+ run_label=run_label,
706
+ target=_requirements_target(
707
+ effective_package_resolution,
708
+ app_key=app_key if isinstance(app_key, str) else "",
709
+ update_only=_target_is_update_only(resolved_target),
710
+ ),
711
+ repair_patch={},
712
+ )
713
+ return self._requirements_success_response(
714
+ mode=mode,
715
+ title=title,
716
+ requirement_text=requirement_text,
717
+ package_resolution=effective_package_resolution,
718
+ layout_style=layout_style,
719
+ publish=publish,
720
+ run_label=run_label,
721
+ generated=generated,
722
+ stage_result=stage_result,
723
+ include_generated_spec=include_generated_spec,
724
+ )
725
+
726
+ @tool_cn_name("搭建流程")
727
+ def solution_build_flow(
728
+ self,
729
+ *,
730
+ profile: str,
731
+ mode: str,
732
+ build_id: str,
733
+ flow_spec: dict[str, Any],
734
+ publish: bool,
735
+ run_label: str | None,
736
+ repair_patch: dict[str, Any],
737
+ ) -> dict[str, Any]:
738
+ """执行方案相关逻辑。"""
739
+ mode = _normalize_staged_build_mode(mode)
740
+ return self._stage_build(
741
+ profile=profile,
742
+ mode=mode,
743
+ build_id=build_id,
744
+ stage_name="app_flow",
745
+ stage_payload=flow_spec,
746
+ stage_model=FlowBuildSpec,
747
+ publish=publish,
748
+ run_label=run_label,
749
+ target={},
750
+ repair_patch=repair_patch,
751
+ public_stage_name="flow",
752
+ tool_name="solution_build_flow",
753
+ projection_name="flow",
754
+ )
755
+
756
+ @tool_cn_name("一键搭建全部")
757
+ def solution_build_all(
758
+ self,
759
+ *,
760
+ profile: str,
761
+ mode: str,
762
+ build_id: str,
763
+ solution_spec: dict[str, Any],
764
+ publish: bool,
765
+ run_label: str | None,
766
+ target: dict[str, Any],
767
+ repair_patch: dict[str, Any],
768
+ verify: bool,
769
+ ) -> dict[str, Any]:
770
+ """执行方案相关逻辑。"""
771
+ mode = _normalize_staged_build_mode(mode)
772
+ if mode in {"preflight", "plan"} and not build_id:
773
+ build_id = _generate_build_id(run_label=run_label, stage_name="all")
774
+ if mode not in STAGED_BUILD_MODES:
775
+ return self._all_failure_response(build_id, mode, "config", "mode must be one of: preflight, plan, apply, repair")
776
+ if not build_id:
777
+ return self._all_failure_response(build_id, mode, "config", "build_id is required")
778
+ if not isinstance(solution_spec, dict):
779
+ return self._all_failure_response(build_id, mode, "config", "solution_spec must be an object")
780
+ if mode != "repair" and not solution_spec:
781
+ return self._all_failure_response(build_id, mode, "config", "solution_spec must be a non-empty object")
782
+ if mode != "repair" and repair_patch:
783
+ return self._all_failure_response(build_id, mode, "config", "repair_patch is only supported in repair mode")
784
+
785
+ def runner() -> dict[str, Any]:
786
+ try:
787
+ base_solution_spec = deepcopy(solution_spec)
788
+ if mode == "repair" and not base_solution_spec:
789
+ assembly = BuildAssemblyStore.open(build_id=build_id, create=False)
790
+ base_solution_spec = assembly.get_manifest()
791
+ if not base_solution_spec.get("entities"):
792
+ return self._all_failure_response(
793
+ build_id,
794
+ mode,
795
+ "config",
796
+ "repair requires a stored build manifest or a non-empty solution_spec",
797
+ )
798
+ resolved_solution_spec = self._resolve_full_solution_spec(
799
+ solution_spec=base_solution_spec,
800
+ mode=mode,
801
+ repair_patch=repair_patch,
802
+ )
803
+ stage_payloads = _split_solution_spec_into_stage_payloads(resolved_solution_spec)
804
+ stage_results: dict[str, Any] = {}
805
+ overall_errors: list[dict[str, Any]] = []
806
+ if mode == "preflight":
807
+ overall_status = "preflighted"
808
+ elif mode == "plan":
809
+ overall_status = "planned"
810
+ else:
811
+ overall_status = "success"
812
+
813
+ for stage_name in STAGE_ORDER:
814
+ stage_payload = stage_payloads[stage_name]
815
+ if _stage_payload_is_empty(stage_name, stage_payload):
816
+ assembly = BuildAssemblyStore.open(build_id=build_id)
817
+ assembly.add_stage_history(
818
+ {
819
+ "stage": stage_name,
820
+ "mode": mode,
821
+ "status": "skipped",
822
+ "reason": _stage_skip_reason(stage_name),
823
+ "execution_step_count": 0,
824
+ }
825
+ )
826
+ stage_results[stage_name] = self._stage_skip_response(
827
+ build_id=build_id,
828
+ stage_name=stage_name,
829
+ mode=mode,
830
+ assembly=assembly,
831
+ tool_name=STAGE_TOOL_NAMES.get(stage_name, "solution_build_app_flow"),
832
+ )
833
+ continue
834
+
835
+ if stage_name == "app_flow":
836
+ result = self._stage_build(
837
+ profile=profile,
838
+ mode=mode,
839
+ build_id=build_id,
840
+ stage_name=stage_name,
841
+ stage_payload=stage_payload,
842
+ stage_model=AppFlowBuildSpec,
843
+ publish=publish,
844
+ run_label=run_label,
845
+ target=target,
846
+ repair_patch={},
847
+ )
848
+ elif stage_name == "views":
849
+ result = self._stage_build(
850
+ profile=profile,
851
+ mode=mode,
852
+ build_id=build_id,
853
+ stage_name=stage_name,
854
+ stage_payload=stage_payload,
855
+ stage_model=ViewsBuildSpec,
856
+ publish=publish,
857
+ run_label=run_label,
858
+ target={},
859
+ repair_patch={},
860
+ )
861
+ elif stage_name == "analytics_portal":
862
+ result = self._stage_build(
863
+ profile=profile,
864
+ mode=mode,
865
+ build_id=build_id,
866
+ stage_name=stage_name,
867
+ stage_payload=stage_payload,
868
+ stage_model=AnalyticsPortalBuildSpec,
869
+ publish=publish,
870
+ run_label=run_label,
871
+ target={},
872
+ repair_patch={},
873
+ )
874
+ else:
875
+ result = self._stage_build(
876
+ profile=profile,
877
+ mode=mode,
878
+ build_id=build_id,
879
+ stage_name=stage_name,
880
+ stage_payload=stage_payload,
881
+ stage_model=NavigationBuildSpec,
882
+ publish=publish,
883
+ run_label=run_label,
884
+ target={},
885
+ repair_patch={},
886
+ )
887
+ stage_results[stage_name] = result
888
+ if result.get("status") == "failed":
889
+ overall_status = "failed"
890
+ overall_errors = _sanitize_errors(result.get("errors", []))
891
+ break
892
+ if mode in {"apply", "repair"} and result.get("status") != "success":
893
+ overall_status = str(result.get("status"))
894
+ elif mode == "plan" and result.get("status") != "planned":
895
+ overall_status = str(result.get("status"))
896
+ elif mode == "preflight" and result.get("status") != "preflighted":
897
+ overall_status = str(result.get("status"))
898
+
899
+ assembly = BuildAssemblyStore.open(build_id=build_id)
900
+ if overall_status != "failed" and mode in {"apply", "repair"}:
901
+ assembly.mark_status("success")
902
+ build_summary = _build_summary(assembly)
903
+ artifacts = assembly.get_artifacts()
904
+ verification = None
905
+ if verify and overall_status == "success":
906
+ verification = self._verify_solution_build(
907
+ profile=profile,
908
+ build_id=build_id,
909
+ artifacts=artifacts,
910
+ solution_spec=resolved_solution_spec,
911
+ )
912
+
913
+ return {
914
+ "build_id": build_id,
915
+ "mode": mode,
916
+ "normalized_solution_spec": resolved_solution_spec,
917
+ "stage_results": stage_results,
918
+ "stage_statuses": {stage: str(result.get("status")) for stage, result in stage_results.items()},
919
+ "artifacts": artifacts,
920
+ "status": overall_status,
921
+ "errors": overall_errors,
922
+ "verification": verification,
923
+ "build_summary": build_summary,
924
+ "build_path": build_summary.get("build_path"),
925
+ "suggested_next_call": _solution_build_next_call(tool_name="solution_build_all", mode=mode, build_id=build_id),
926
+ }
927
+ except ValidationError as exc:
928
+ return self._all_failure_response(build_id, mode, "validation", exc.errors())
929
+ except FileNotFoundError:
930
+ return self._all_failure_response(
931
+ build_id,
932
+ mode,
933
+ "config",
934
+ "repair requires an existing build_id with a stored build manifest",
935
+ )
936
+ except ValueError as exc:
937
+ return self._all_failure_response(build_id, mode, "config", str(exc))
938
+ except Exception as exc: # noqa: BLE001
939
+ return self._all_failure_response(build_id, mode, "runtime", str(exc))
940
+
941
+ if mode in {"preflight", "plan"}:
942
+ return runner()
943
+ return self._run(profile, lambda _session_profile, _context: runner())
944
+
945
+ @tool_cn_name("搭建视图")
946
+ def solution_build_views(
947
+ self,
948
+ *,
949
+ profile: str,
950
+ mode: str,
951
+ build_id: str,
952
+ views_spec: dict[str, Any],
953
+ publish: bool,
954
+ run_label: str | None,
955
+ repair_patch: dict[str, Any],
956
+ ) -> dict[str, Any]:
957
+ """执行方案相关逻辑。"""
958
+ mode = _normalize_staged_build_mode(mode)
959
+ return self._stage_build(
960
+ profile=profile,
961
+ mode=mode,
962
+ build_id=build_id,
963
+ stage_name="views",
964
+ stage_payload=views_spec,
965
+ stage_model=ViewsBuildSpec,
966
+ publish=publish,
967
+ run_label=run_label,
968
+ target={},
969
+ repair_patch=repair_patch,
970
+ )
971
+
972
+ @tool_cn_name("方案 Schema 示例")
973
+ def solution_schema_example(
974
+ self,
975
+ *,
976
+ stage: str,
977
+ intent: str,
978
+ include_examples: bool = False,
979
+ include_selected_example: bool = False,
980
+ ) -> dict[str, Any]:
981
+ """执行方案相关逻辑。"""
982
+ resolved_stage = _normalize_solution_schema_stage(stage)
983
+ resolved_intent = _normalize_solution_schema_intent(intent)
984
+ if resolved_stage is None:
985
+ return {
986
+ "stage": stage,
987
+ "intent": intent,
988
+ "status": "failed",
989
+ "errors": [{"category": "config", "detail": f"stage must be one of: {', '.join(sorted(SOLUTION_SCHEMA_STAGES))}"}],
990
+ "available_stages": sorted(SOLUTION_SCHEMA_STAGES),
991
+ **_solution_error_fields(
992
+ category="config",
993
+ detail=f"stage must be one of: {', '.join(sorted(SOLUTION_SCHEMA_STAGES))}",
994
+ suggested_next_call={"tool_name": "solution_schema_example", "arguments": {"stage": "app", "intent": "minimal"}},
995
+ ),
996
+ }
997
+ if resolved_intent is None:
998
+ return {
999
+ "stage": resolved_stage,
1000
+ "intent": intent,
1001
+ "status": "failed",
1002
+ "errors": [{"category": "config", "detail": f"intent must be one of: {', '.join(sorted(SOLUTION_SCHEMA_INTENTS))}"}],
1003
+ "available_intents": sorted(SOLUTION_SCHEMA_INTENTS),
1004
+ **_solution_error_fields(
1005
+ category="config",
1006
+ detail=f"intent must be one of: {', '.join(sorted(SOLUTION_SCHEMA_INTENTS))}",
1007
+ suggested_next_call={"tool_name": "solution_schema_example", "arguments": {"stage": resolved_stage, "intent": "minimal"}},
1008
+ ),
1009
+ }
1010
+ response = _solution_schema_example_payload(
1011
+ stage=resolved_stage,
1012
+ intent=resolved_intent,
1013
+ include_examples=include_examples,
1014
+ include_selected_example=include_selected_example,
1015
+ )
1016
+ response["requested_stage"] = stage
1017
+ response["requested_intent"] = intent
1018
+ response["resolved_stage"] = resolved_stage
1019
+ response["resolved_intent"] = resolved_intent
1020
+ return response
1021
+
1022
+ @tool_cn_name("搭建分析门户")
1023
+ def solution_build_analytics_portal(
1024
+ self,
1025
+ *,
1026
+ profile: str,
1027
+ mode: str,
1028
+ build_id: str,
1029
+ analytics_portal_spec: dict[str, Any],
1030
+ publish: bool,
1031
+ run_label: str | None,
1032
+ repair_patch: dict[str, Any],
1033
+ ) -> dict[str, Any]:
1034
+ """执行方案相关逻辑。"""
1035
+ mode = _normalize_staged_build_mode(mode)
1036
+ return self._stage_build(
1037
+ profile=profile,
1038
+ mode=mode,
1039
+ build_id=build_id,
1040
+ stage_name="analytics_portal",
1041
+ stage_payload=analytics_portal_spec,
1042
+ stage_model=AnalyticsPortalBuildSpec,
1043
+ publish=publish,
1044
+ run_label=run_label,
1045
+ target={},
1046
+ repair_patch=repair_patch,
1047
+ )
1048
+
1049
+ @tool_cn_name("搭建导航")
1050
+ def solution_build_navigation(
1051
+ self,
1052
+ *,
1053
+ profile: str,
1054
+ mode: str,
1055
+ build_id: str,
1056
+ navigation_spec: dict[str, Any],
1057
+ publish: bool,
1058
+ run_label: str | None,
1059
+ repair_patch: dict[str, Any],
1060
+ ) -> dict[str, Any]:
1061
+ """执行方案相关逻辑。"""
1062
+ mode = _normalize_staged_build_mode(mode)
1063
+ return self._stage_build(
1064
+ profile=profile,
1065
+ mode=mode,
1066
+ build_id=build_id,
1067
+ stage_name="navigation",
1068
+ stage_payload=navigation_spec,
1069
+ stage_model=NavigationBuildSpec,
1070
+ publish=publish,
1071
+ run_label=run_label,
1072
+ target={},
1073
+ repair_patch=repair_patch,
1074
+ )
1075
+
1076
+ @tool_cn_name("搭建状态")
1077
+ def solution_build_status(self, *, build_id: str) -> dict[str, Any]:
1078
+ """执行方案相关逻辑。"""
1079
+ if not build_id:
1080
+ error_fields = _solution_error_fields(
1081
+ category="config",
1082
+ detail="build_id is required",
1083
+ suggested_next_call=None,
1084
+ )
1085
+ return {
1086
+ "build_id": build_id,
1087
+ "status": "failed",
1088
+ "errors": [{"category": "config", "detail": "build_id is required"}],
1089
+ "build_path": None,
1090
+ **error_fields,
1091
+ }
1092
+ assembly = BuildAssemblyStore.open(build_id=build_id)
1093
+ summary = _build_summary(assembly)
1094
+ summary["errors"] = []
1095
+ return summary
1096
+
1097
+ def _stage_build(
1098
+ self,
1099
+ *,
1100
+ profile: str,
1101
+ mode: str,
1102
+ build_id: str,
1103
+ stage_name: str,
1104
+ stage_payload: dict[str, Any],
1105
+ stage_model: type[BaseModel],
1106
+ publish: bool,
1107
+ run_label: str | None,
1108
+ target: dict[str, Any],
1109
+ repair_patch: dict[str, Any],
1110
+ public_stage_name: str | None = None,
1111
+ tool_name: str | None = None,
1112
+ projection_name: str | None = None,
1113
+ ) -> dict[str, Any]:
1114
+ """执行内部辅助逻辑。"""
1115
+ mode = _normalize_staged_build_mode(mode)
1116
+ public_stage_name = public_stage_name or stage_name
1117
+ tool_name = tool_name or STAGE_TOOL_NAMES.get(stage_name, "solution_build_app_flow")
1118
+ projection_name = projection_name or public_stage_name
1119
+ if mode in {"preflight", "plan"} and not build_id:
1120
+ build_id = _generate_build_id(run_label=run_label, stage_name=stage_name)
1121
+ assembly = BuildAssemblyStore.open(build_id=build_id) if build_id else None
1122
+ if mode not in STAGED_BUILD_MODES:
1123
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", "mode must be one of: preflight, plan, apply, repair", assembly, tool_name=tool_name)
1124
+ if not build_id:
1125
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", "build_id is required", assembly, tool_name=tool_name)
1126
+ if mode != "repair" and repair_patch:
1127
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", "repair_patch is only supported in repair mode", assembly, tool_name=tool_name)
1128
+ if stage_name != "app_flow" and target:
1129
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", "target is only supported for the app_flow stage", assembly, tool_name=tool_name)
1130
+
1131
+ def runner() -> dict[str, Any]:
1132
+ return self._execute_stage_build(
1133
+ profile=profile,
1134
+ build_id=build_id,
1135
+ stage_name=stage_name,
1136
+ public_stage_name=public_stage_name,
1137
+ mode=mode,
1138
+ assembly=assembly,
1139
+ stage_payload=stage_payload,
1140
+ stage_model=stage_model,
1141
+ publish=publish,
1142
+ run_label=run_label,
1143
+ target=target,
1144
+ repair_patch=repair_patch,
1145
+ tool_name=tool_name,
1146
+ projection_name=projection_name,
1147
+ )
1148
+
1149
+ if mode in {"preflight", "plan"}:
1150
+ return runner()
1151
+ return self._run(profile, lambda _session_profile, _context: runner())
1152
+
1153
+ def _execute_stage_build(
1154
+ self,
1155
+ *,
1156
+ profile: str,
1157
+ build_id: str,
1158
+ stage_name: str,
1159
+ public_stage_name: str,
1160
+ mode: str,
1161
+ assembly: BuildAssemblyStore,
1162
+ stage_payload: dict[str, Any],
1163
+ stage_model: type[BaseModel],
1164
+ publish: bool,
1165
+ run_label: str | None,
1166
+ target: dict[str, Any],
1167
+ repair_patch: dict[str, Any],
1168
+ tool_name: str,
1169
+ projection_name: str,
1170
+ ) -> dict[str, Any]:
1171
+ """执行内部辅助逻辑。"""
1172
+ try:
1173
+ resolved_stage_payload = self._resolve_stage_payload(
1174
+ assembly=assembly,
1175
+ stage_name=stage_name,
1176
+ mode=mode,
1177
+ incoming_stage_payload=stage_payload,
1178
+ repair_patch=repair_patch,
1179
+ stage_model=stage_model,
1180
+ projection_name=projection_name,
1181
+ )
1182
+ missing_prerequisites = _missing_stage_prerequisites(
1183
+ stage_name=public_stage_name,
1184
+ assembly=assembly,
1185
+ stage_payload=resolved_stage_payload,
1186
+ )
1187
+ if missing_prerequisites:
1188
+ raise ValueError("; ".join(missing_prerequisites))
1189
+ merged_manifest = _merge_stage_manifest(
1190
+ stage_name=stage_name,
1191
+ manifest=assembly.get_manifest(),
1192
+ stage_payload=resolved_stage_payload,
1193
+ )
1194
+ parsed_manifest = BuildManifest.model_validate(merged_manifest)
1195
+ normalized_manifest = normalize_solution_spec(parsed_manifest)
1196
+ compiled = compile_solution(normalized_manifest)
1197
+ existing_app_targets = _resolve_target_app_keys(
1198
+ target=target,
1199
+ entity_ids=[entity.entity_id for entity in compiled.entities],
1200
+ )
1201
+ update_only = _target_is_update_only(target) or bool(existing_app_targets)
1202
+ if update_only and not existing_app_targets:
1203
+ raise ValueError("update target requires app_key or target.app_keys")
1204
+ package_tag_id = _resolve_target_package_tag_id(target)
1205
+ if package_tag_id is not None:
1206
+ compiled = _bind_existing_package_target(compiled, package_tag_id)
1207
+ elif update_only:
1208
+ compiled = _bind_existing_app_target(compiled)
1209
+ filtered_compiled = _filter_compiled_solution(
1210
+ compiled,
1211
+ stage_name=stage_name,
1212
+ stage_payload=resolved_stage_payload,
1213
+ stage_variant=public_stage_name,
1214
+ )
1215
+ assembly.set_stage_spec(stage_name, resolved_stage_payload)
1216
+ assembly.set_manifest(normalized_manifest.model_dump(mode="json"))
1217
+ if existing_app_targets:
1218
+ _seed_existing_app_artifacts(assembly=assembly, existing_app_targets=existing_app_targets)
1219
+
1220
+ if mode in {"preflight", "plan"}:
1221
+ status = "preflighted" if mode == "preflight" else "planned"
1222
+ assembly.add_stage_history(
1223
+ {
1224
+ "stage": stage_name,
1225
+ "mode": mode,
1226
+ "status": status,
1227
+ "execution_step_count": len(filtered_compiled.execution_plan.steps),
1228
+ }
1229
+ )
1230
+ assembly.mark_status("draft")
1231
+ return self._stage_success_response(
1232
+ build_id=build_id,
1233
+ stage_name=public_stage_name,
1234
+ mode=mode,
1235
+ normalized_manifest=normalized_manifest,
1236
+ compiled=filtered_compiled,
1237
+ artifacts=assembly.get_artifacts(),
1238
+ step_results={},
1239
+ errors=[],
1240
+ run_path=None,
1241
+ build_path=str(assembly.path),
1242
+ status=status,
1243
+ build_summary=_build_summary(assembly),
1244
+ tool_name=tool_name,
1245
+ )
1246
+
1247
+ request_fingerprint = fingerprint_payload(
1248
+ {
1249
+ "stage": stage_name,
1250
+ "mode": "apply" if mode == "repair" else mode,
1251
+ "publish": publish,
1252
+ "build_manifest": normalized_manifest.model_dump(mode="json"),
1253
+ "stage_payload": resolved_stage_payload,
1254
+ "target": target,
1255
+ }
1256
+ )
1257
+ store = RunArtifactStore.open(
1258
+ idempotency_key=_stage_run_id(build_id, stage_name),
1259
+ normalized_solution_spec=normalized_manifest.model_dump(mode="json"),
1260
+ request_fingerprint=request_fingerprint,
1261
+ run_label=run_label,
1262
+ initial_artifacts=assembly.get_artifacts(),
1263
+ )
1264
+ if mode == "apply":
1265
+ store.ensure_apply_fingerprint(request_fingerprint)
1266
+ if package_tag_id is not None:
1267
+ store.set_artifact("package", "tag_id", package_tag_id)
1268
+ store.set_artifact("package", "reused", True)
1269
+ result = self._build_executor().execute(
1270
+ profile=profile,
1271
+ compiled=filtered_compiled,
1272
+ store=store,
1273
+ publish=publish,
1274
+ mode=mode,
1275
+ )
1276
+ assembly.set_artifacts(result["artifacts"])
1277
+ stage_verification = None
1278
+ effective_status = result["status"]
1279
+ if result["status"] == "success":
1280
+ stage_verification = self._verify_stage_write_result(
1281
+ profile=profile,
1282
+ build_id=build_id,
1283
+ public_stage_name=public_stage_name,
1284
+ artifacts=result["artifacts"],
1285
+ solution_spec=normalized_manifest.model_dump(mode="json"),
1286
+ target=target,
1287
+ )
1288
+ if isinstance(stage_verification, dict) and stage_verification.get("status") == "partial":
1289
+ effective_status = "partial_success"
1290
+ failure_signature = None
1291
+ stage_failure_count = 0
1292
+ next_strategy = None
1293
+ suggested_next_call_override = None
1294
+ if effective_status == "failed":
1295
+ failure_signature, stage_failure_count = self._record_failure_signature(
1296
+ assembly=assembly,
1297
+ stage_name=public_stage_name,
1298
+ stage_payload=resolved_stage_payload,
1299
+ errors=result.get("errors", []),
1300
+ )
1301
+ if failure_signature and stage_failure_count >= 2 and public_stage_name == "app":
1302
+ next_strategy = "switch_to_explicit_app_stage"
1303
+ suggested_next_call_override = {
1304
+ "tool_name": "solution_build_app",
1305
+ "arguments": {"mode": "plan", "build_id": build_id},
1306
+ }
1307
+ assembly.add_stage_history(
1308
+ {
1309
+ "stage": stage_name,
1310
+ "mode": mode,
1311
+ "status": effective_status,
1312
+ "run_path": result["run_path"],
1313
+ "execution_step_count": len(filtered_compiled.execution_plan.steps),
1314
+ }
1315
+ )
1316
+ assembly.mark_status(_build_status_for_stage(stage_name, effective_status))
1317
+ response = self._stage_success_response(
1318
+ build_id=build_id,
1319
+ stage_name=public_stage_name,
1320
+ mode=mode,
1321
+ normalized_manifest=normalized_manifest,
1322
+ compiled=filtered_compiled,
1323
+ artifacts=result["artifacts"],
1324
+ step_results=result["step_results"],
1325
+ errors=result["errors"],
1326
+ run_path=result["run_path"],
1327
+ build_path=str(assembly.path),
1328
+ status=effective_status,
1329
+ build_summary=_build_summary(assembly),
1330
+ tool_name=tool_name,
1331
+ verification=stage_verification,
1332
+ )
1333
+ if effective_status == "failed":
1334
+ response["failure_signature"] = failure_signature
1335
+ response["stage_failure_count"] = stage_failure_count or 1
1336
+ if next_strategy:
1337
+ response["next_strategy"] = next_strategy
1338
+ if suggested_next_call_override is not None:
1339
+ response["suggested_next_call"] = suggested_next_call_override
1340
+ return response
1341
+ except ValidationError as exc:
1342
+ return self._stage_failure_response(build_id, public_stage_name, mode, "validation", exc.errors(), assembly, tool_name=tool_name)
1343
+ except ValueError as exc:
1344
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", str(exc), assembly, tool_name=tool_name)
1345
+ except Exception as exc: # noqa: BLE001
1346
+ return self._stage_failure_response(build_id, public_stage_name, mode, "runtime", str(exc), assembly, tool_name=tool_name)
1347
+
1348
+ def _resolve_stage_payload(
1349
+ self,
1350
+ *,
1351
+ assembly: BuildAssemblyStore,
1352
+ stage_name: str,
1353
+ mode: str,
1354
+ incoming_stage_payload: dict[str, Any],
1355
+ repair_patch: dict[str, Any],
1356
+ stage_model: type[BaseModel],
1357
+ projection_name: str,
1358
+ ) -> dict[str, Any]:
1359
+ """执行内部辅助逻辑。"""
1360
+ existing_stage_payload = (assembly.data.get("stage_specs", {}) or {}).get(stage_name, {}) or {}
1361
+ base_payload: dict[str, Any] = {}
1362
+ if mode == "repair":
1363
+ base_payload = deepcopy(existing_stage_payload)
1364
+ if incoming_stage_payload:
1365
+ base_payload = deep_merge(base_payload, incoming_stage_payload)
1366
+ if repair_patch:
1367
+ base_payload = deep_merge(base_payload, repair_patch)
1368
+ if not base_payload:
1369
+ raise ValueError(f"{stage_name} stage does not have a stored spec to repair")
1370
+ else:
1371
+ if incoming_stage_payload:
1372
+ base_payload = deepcopy(incoming_stage_payload)
1373
+ elif existing_stage_payload:
1374
+ base_payload = deepcopy(existing_stage_payload)
1375
+ else:
1376
+ raise ValueError(f"{stage_name} stage spec is required")
1377
+ parsed: BaseModel | None = None
1378
+ try:
1379
+ parsed = stage_model.model_validate(base_payload)
1380
+ except ValidationError as original_error:
1381
+ if _looks_like_full_solution_spec(base_payload):
1382
+ parsed = stage_model.model_validate(_project_stage_payload(stage_name=projection_name, solution_spec=base_payload))
1383
+ else:
1384
+ raise original_error
1385
+ resolved = parsed.model_dump(mode="json", exclude_unset=True)
1386
+ if not resolved:
1387
+ raise ValueError(f"{stage_name} stage spec is required")
1388
+ return resolved
1389
+
1390
+ def _resolve_full_solution_spec(
1391
+ self,
1392
+ *,
1393
+ solution_spec: dict[str, Any],
1394
+ mode: str,
1395
+ repair_patch: dict[str, Any],
1396
+ ) -> dict[str, Any]:
1397
+ """执行内部辅助逻辑。"""
1398
+ base_payload = deepcopy(solution_spec)
1399
+ if mode == "repair" and repair_patch:
1400
+ base_payload = deep_merge(base_payload, repair_patch)
1401
+ parsed = SolutionSpec.model_validate(base_payload)
1402
+ return parsed.model_dump(mode="json", exclude_unset=True)
1403
+
1404
+ def _build_executor(self) -> SolutionExecutor:
1405
+ """执行内部辅助逻辑。"""
1406
+ return SolutionExecutor(
1407
+ workspace_tools=WorkspaceTools(self.sessions, self.backend),
1408
+ package_tools=PackageTools(self.sessions, self.backend),
1409
+ role_tools=RoleTools(self.sessions, self.backend),
1410
+ app_tools=AppTools(self.sessions, self.backend),
1411
+ record_tools=RecordTools(self.sessions, self.backend),
1412
+ workflow_tools=WorkflowTools(self.sessions, self.backend),
1413
+ view_tools=ViewTools(self.sessions, self.backend),
1414
+ chart_tools=QingbiReportTools(self.sessions, self.backend),
1415
+ portal_tools=PortalTools(self.sessions, self.backend),
1416
+ navigation_tools=NavigationTools(self.sessions, self.backend),
1417
+ )
1418
+
1419
+ def _verify_solution_build(
1420
+ self,
1421
+ *,
1422
+ profile: str,
1423
+ build_id: str,
1424
+ artifacts: dict[str, Any],
1425
+ solution_spec: dict[str, Any],
1426
+ ) -> dict[str, Any]:
1427
+ """执行内部辅助逻辑。"""
1428
+ verification = {
1429
+ "build_id": build_id,
1430
+ "status": "success",
1431
+ "errors": [],
1432
+ "package": {},
1433
+ "apps": [],
1434
+ "portal": {},
1435
+ "navigation": {},
1436
+ }
1437
+ package_tools = PackageTools(self.sessions, self.backend)
1438
+ app_tools = AppTools(self.sessions, self.backend)
1439
+ portal_tools = PortalTools(self.sessions, self.backend)
1440
+ navigation_tools = NavigationTools(self.sessions, self.backend)
1441
+ record_tools = RecordTools(self.sessions, self.backend)
1442
+ entity_specs = {
1443
+ entity.get("entity_id"): entity
1444
+ for entity in solution_spec.get("entities", [])
1445
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
1446
+ }
1447
+ field_maps_artifact = artifacts.get("field_maps", {}) if isinstance(artifacts.get("field_maps"), dict) else {}
1448
+
1449
+ try:
1450
+ package_artifact = artifacts.get("package", {}) if isinstance(artifacts.get("package"), dict) else {}
1451
+ tag_id = package_artifact.get("tag_id")
1452
+ if isinstance(tag_id, int) and tag_id > 0:
1453
+ package_detail = package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True).get("result") or {}
1454
+ verification["package"] = {
1455
+ "tag_id": tag_id,
1456
+ "name": package_detail.get("tagName"),
1457
+ "item_count": len(package_detail.get("tagItems") or []),
1458
+ }
1459
+ except Exception as exc: # noqa: BLE001
1460
+ _append_verification_error(verification, "package", exc)
1461
+
1462
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
1463
+ records_artifact = artifacts.get("records", {}) if isinstance(artifacts.get("records"), dict) else {}
1464
+ for entity_id, app_info in apps_artifact.items():
1465
+ try:
1466
+ if not isinstance(app_info, dict):
1467
+ continue
1468
+ app_key = app_info.get("app_key")
1469
+ if not isinstance(app_key, str) or not app_key:
1470
+ continue
1471
+ entity_spec = entity_specs.get(entity_id)
1472
+ field_meta = field_maps_artifact.get(entity_id, {}) if isinstance(field_maps_artifact.get(entity_id), dict) else {}
1473
+ base_info = app_tools.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1474
+ record_page = _readback_record_page(
1475
+ record_tools=record_tools,
1476
+ profile=profile,
1477
+ app_key=app_key,
1478
+ entity_spec=entity_spec,
1479
+ field_meta=field_meta,
1480
+ )
1481
+ rows = record_page.get("list") or []
1482
+ returned_rows = _coerce_count(record_page.get("returned_rows"))
1483
+ if returned_rows is None:
1484
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1485
+ backend_reported_total = _coerce_count(record_page.get("backend_reported_total"))
1486
+ effective_count = _coerce_count(record_page.get("effective_count"))
1487
+ if effective_count is None:
1488
+ effective_count = max(backend_reported_total or 0, returned_rows)
1489
+ seeded_records = records_artifact.get(entity_id, {}) if isinstance(records_artifact.get(entity_id), dict) else {}
1490
+ app_verification = {
1491
+ "entity_id": entity_id,
1492
+ "app_key": app_key,
1493
+ "app_name": base_info.get("formTitle") or app_info.get("display_name") or app_info.get("name"),
1494
+ "app_icon": base_info.get("appIcon"),
1495
+ "returned_rows": returned_rows,
1496
+ "effective_count": effective_count,
1497
+ "list_type": _coerce_count(record_page.get("list_type")),
1498
+ "list_type_label": record_page.get("list_type_label"),
1499
+ "seeded_records": len(seeded_records),
1500
+ "sample_titles": _extract_record_titles(rows, entity_spec=entity_spec, field_meta=field_meta),
1501
+ }
1502
+ if backend_reported_total is not None:
1503
+ app_verification["backend_reported_total"] = backend_reported_total
1504
+ verification["apps"].append(app_verification)
1505
+ except Exception as exc: # noqa: BLE001
1506
+ _append_verification_error(verification, f"app:{entity_id}", exc)
1507
+
1508
+ try:
1509
+ portal_artifact = artifacts.get("portal", {}) if isinstance(artifacts.get("portal"), dict) else {}
1510
+ dash_key = portal_artifact.get("dash_key")
1511
+ if isinstance(dash_key, str) and dash_key:
1512
+ published = portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
1513
+ draft = portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
1514
+ verification["portal"] = {
1515
+ "dash_key": dash_key,
1516
+ "name": published.get("dashName") or draft.get("dashName") or published.get("name") or draft.get("name"),
1517
+ "published_component_count": _count_portal_components(published),
1518
+ "draft_component_count": _count_portal_components(draft),
1519
+ }
1520
+ except Exception as exc: # noqa: BLE001
1521
+ _append_verification_error(verification, "portal", exc)
1522
+
1523
+ try:
1524
+ navigation_artifact = artifacts.get("navigation", {}) if isinstance(artifacts.get("navigation"), dict) else {}
1525
+ status = navigation_tools.navigation_get_status(profile=profile).get("result") or {}
1526
+ items = navigation_artifact.get("items") or []
1527
+ verification["navigation"] = {
1528
+ "navigation_id": status.get("navigationId") or status.get("id"),
1529
+ "publish_status": status.get("publishStatus"),
1530
+ "item_count": len(items) if isinstance(items, list) else 0,
1531
+ "items": _extract_navigation_items(items),
1532
+ }
1533
+ except Exception as exc: # noqa: BLE001
1534
+ _append_verification_error(verification, "navigation", exc)
1535
+
1536
+ if verification["errors"]:
1537
+ verification["status"] = "partial"
1538
+ return verification
1539
+
1540
+ def _verify_stage_write_result(
1541
+ self,
1542
+ *,
1543
+ profile: str,
1544
+ build_id: str,
1545
+ public_stage_name: str,
1546
+ artifacts: dict[str, Any],
1547
+ solution_spec: dict[str, Any],
1548
+ target: dict[str, Any],
1549
+ ) -> dict[str, Any] | None:
1550
+ """执行内部辅助逻辑。"""
1551
+ if public_stage_name != "app":
1552
+ return None
1553
+ app_tools = AppTools(self.sessions, self.backend)
1554
+ expected_package_tag_id = _resolve_target_package_tag_id(target)
1555
+ verification: dict[str, Any] = {
1556
+ "stage": public_stage_name,
1557
+ "status": "success",
1558
+ "expected_package_tag_id": expected_package_tag_id,
1559
+ "package_attached": None if expected_package_tag_id is None else True,
1560
+ "apps": [],
1561
+ "views_created": [],
1562
+ "views_strategy": "not_included",
1563
+ "errors": [],
1564
+ }
1565
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
1566
+ views_artifact = artifacts.get("views", {}) if isinstance(artifacts.get("views"), dict) else {}
1567
+ entity_specs = {
1568
+ entity.get("entity_id"): entity
1569
+ for entity in solution_spec.get("entities", [])
1570
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
1571
+ }
1572
+
1573
+ for entity_id, app_info in apps_artifact.items():
1574
+ if not isinstance(app_info, dict):
1575
+ continue
1576
+ app_key = app_info.get("app_key")
1577
+ if not isinstance(app_key, str) or not app_key:
1578
+ continue
1579
+ try:
1580
+ base_info = app_tools.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1581
+ raw_tag_ids = base_info.get("tagIds") if isinstance(base_info.get("tagIds"), list) else []
1582
+ tag_ids_after = [tag_id for tag_id in (_coerce_count(value) for value in raw_tag_ids) if tag_id is not None]
1583
+ package_attached = None if expected_package_tag_id is None else expected_package_tag_id in tag_ids_after
1584
+ if package_attached is False:
1585
+ verification["status"] = "partial"
1586
+ verification["package_attached"] = False
1587
+ verification["errors"].append(
1588
+ {
1589
+ "category": "verification",
1590
+ "detail": f"app '{app_key}' is not attached to expected package '{expected_package_tag_id}'",
1591
+ "entity_id": entity_id,
1592
+ "app_key": app_key,
1593
+ "tag_ids_after": tag_ids_after,
1594
+ }
1595
+ )
1596
+ entity_views = views_artifact.get(entity_id) if isinstance(views_artifact.get(entity_id), dict) else {}
1597
+ created_view_keys = sorted(
1598
+ {
1599
+ str(view_result.get("view_key"))
1600
+ for view_result in entity_views.values()
1601
+ if isinstance(view_result, dict) and isinstance(view_result.get("view_key"), str) and view_result.get("view_key")
1602
+ }
1603
+ )
1604
+ verification["views_created"].extend(created_view_keys)
1605
+ verification["apps"].append(
1606
+ {
1607
+ "entity_id": entity_id,
1608
+ "display_name": entity_specs.get(entity_id, {}).get("display_name"),
1609
+ "app_key": app_key,
1610
+ "tag_ids_after": tag_ids_after,
1611
+ "package_attached": package_attached,
1612
+ "views_created": created_view_keys,
1613
+ "publish_status": base_info.get("appPublishStatus"),
1614
+ }
1615
+ )
1616
+ except Exception as exc: # noqa: BLE001
1617
+ verification["status"] = "partial"
1618
+ verification["errors"].append(
1619
+ {
1620
+ "category": "verification",
1621
+ "detail": str(exc),
1622
+ "entity_id": entity_id,
1623
+ "app_key": app_key,
1624
+ }
1625
+ )
1626
+ if verification["views_created"]:
1627
+ verification["views_strategy"] = "created"
1628
+ else:
1629
+ verification["suggested_next_call"] = {
1630
+ "tool_name": "solution_build_views",
1631
+ "arguments": {"mode": "plan", "build_id": build_id},
1632
+ }
1633
+ return verification
1634
+
1635
+ def _failure_response(self, mode: str, idempotency_key: str, category: str, detail: Any) -> dict[str, Any]:
1636
+ """执行内部辅助逻辑。"""
1637
+ error_fields = _solution_error_fields(
1638
+ category=category,
1639
+ detail=detail,
1640
+ suggested_next_call=None,
1641
+ stage="all",
1642
+ )
1643
+ return {
1644
+ "mode": mode,
1645
+ "idempotency_key": idempotency_key,
1646
+ "normalized_solution_spec": None,
1647
+ "execution_plan": {"steps": []},
1648
+ "artifacts": {},
1649
+ "step_results": {},
1650
+ "publish_results": {},
1651
+ "status": "failed",
1652
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1653
+ "run_path": None,
1654
+ "verification": None,
1655
+ **error_fields,
1656
+ }
1657
+
1658
+ def _stage_failure_response(
1659
+ self,
1660
+ build_id: str,
1661
+ stage_name: str,
1662
+ mode: str,
1663
+ category: str,
1664
+ detail: Any,
1665
+ assembly: BuildAssemblyStore | None,
1666
+ *,
1667
+ tool_name: str,
1668
+ ) -> dict[str, Any]:
1669
+ """执行内部辅助逻辑。"""
1670
+ error_fields = _solution_error_fields(
1671
+ category=category,
1672
+ detail=detail,
1673
+ suggested_next_call=_solution_build_next_call(
1674
+ tool_name=tool_name,
1675
+ mode=mode,
1676
+ build_id=build_id or None,
1677
+ ),
1678
+ stage=stage_name,
1679
+ )
1680
+ if build_id and stage_name == "app":
1681
+ error_fields["suggested_schema_call"] = None
1682
+ return {
1683
+ "build_id": build_id,
1684
+ "stage": stage_name,
1685
+ "mode": mode,
1686
+ "normalized_build_manifest": None,
1687
+ "execution_plan": {"steps": []},
1688
+ "artifacts": assembly.get_artifacts() if assembly is not None else {},
1689
+ "step_results": {},
1690
+ "publish_results": {},
1691
+ "status": "failed",
1692
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1693
+ "build_path": str(assembly.path) if assembly is not None else None,
1694
+ "run_path": None,
1695
+ "build_summary": _build_summary(assembly) if assembly is not None else None,
1696
+ **error_fields,
1697
+ }
1698
+
1699
+ def _stage_skip_response(
1700
+ self,
1701
+ *,
1702
+ build_id: str,
1703
+ stage_name: str,
1704
+ mode: str,
1705
+ assembly: BuildAssemblyStore,
1706
+ tool_name: str,
1707
+ ) -> dict[str, Any]:
1708
+ """执行内部辅助逻辑。"""
1709
+ return {
1710
+ "build_id": build_id,
1711
+ "stage": stage_name,
1712
+ "mode": mode,
1713
+ "normalized_build_manifest": assembly.get_manifest(),
1714
+ "execution_plan": {"steps": []},
1715
+ "artifacts": assembly.get_artifacts(),
1716
+ "step_results": {},
1717
+ "publish_results": {},
1718
+ "status": "skipped",
1719
+ "errors": [],
1720
+ "build_path": str(assembly.path),
1721
+ "run_path": None,
1722
+ "build_summary": _build_summary(assembly),
1723
+ "suggested_next_call": _solution_build_next_call(
1724
+ tool_name=tool_name,
1725
+ mode=mode,
1726
+ build_id=build_id,
1727
+ ),
1728
+ }
1729
+
1730
+ def _stage_success_response(
1731
+ self,
1732
+ *,
1733
+ build_id: str,
1734
+ stage_name: str,
1735
+ mode: str,
1736
+ normalized_manifest: SolutionSpec,
1737
+ compiled: CompiledSolution,
1738
+ artifacts: dict[str, Any],
1739
+ step_results: dict[str, Any],
1740
+ errors: list[dict[str, Any]],
1741
+ run_path: str | None,
1742
+ build_path: str,
1743
+ status: str,
1744
+ build_summary: dict[str, Any],
1745
+ tool_name: str,
1746
+ verification: dict[str, Any] | None = None,
1747
+ ) -> dict[str, Any]:
1748
+ """执行内部辅助逻辑。"""
1749
+ response = {
1750
+ "build_id": build_id,
1751
+ "stage": stage_name,
1752
+ "mode": mode,
1753
+ "normalized_build_manifest": normalized_manifest.model_dump(mode="json"),
1754
+ "execution_plan": compiled.execution_plan.as_dict(),
1755
+ "artifacts": artifacts,
1756
+ "step_results": step_results,
1757
+ "publish_results": _extract_publish_results(step_results),
1758
+ "status": status,
1759
+ "errors": _sanitize_errors(errors),
1760
+ "build_path": build_path,
1761
+ "run_path": run_path,
1762
+ "verification": verification,
1763
+ "build_summary": build_summary,
1764
+ "suggested_next_call": _solution_build_next_call(
1765
+ tool_name=tool_name,
1766
+ mode=mode,
1767
+ build_id=build_id,
1768
+ ),
1769
+ }
1770
+ if isinstance(verification, dict):
1771
+ for key in ("package_attached", "views_created", "views_strategy"):
1772
+ if key in verification:
1773
+ response[key] = verification.get(key)
1774
+ return response
1775
+
1776
+ def _design_failure_response(self, session_id: str, action: str, category: str, detail: Any) -> dict[str, Any]:
1777
+ """执行内部辅助逻辑。"""
1778
+ error_fields = _solution_error_fields(
1779
+ category=category,
1780
+ detail=detail,
1781
+ suggested_next_call=_design_session_next_call(action=action, session_id=session_id or None),
1782
+ )
1783
+ return {
1784
+ "session_id": session_id,
1785
+ "status": "failed",
1786
+ "current_stage": None,
1787
+ "next_stage": None,
1788
+ "metadata": {},
1789
+ "stage_results": {},
1790
+ "merged_design_spec": {},
1791
+ "normalized_solution_spec": None,
1792
+ "execution_plan": None,
1793
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1794
+ "session_path": None,
1795
+ **error_fields,
1796
+ }
1797
+
1798
+ def _design_summary(self, store: DesignSessionStore) -> dict[str, Any]:
1799
+ """执行内部辅助逻辑。"""
1800
+ summary = store.summary()
1801
+ current_stage = summary["current_stage"]
1802
+ summary["next_stage"] = None if summary["status"] == "finalized" else current_stage
1803
+ summary["errors"] = []
1804
+ summary["suggested_next_call"] = _design_summary_next_call(summary)
1805
+ return summary
1806
+
1807
+ def _all_failure_response(self, build_id: str, mode: str, category: str, detail: Any) -> dict[str, Any]:
1808
+ """执行内部辅助逻辑。"""
1809
+ error_fields = _solution_error_fields(
1810
+ category=category,
1811
+ detail=detail,
1812
+ suggested_next_call=_solution_build_next_call(tool_name="solution_build_all", mode=mode, build_id=build_id or None),
1813
+ stage="all",
1814
+ )
1815
+ return {
1816
+ "build_id": build_id,
1817
+ "mode": mode,
1818
+ "normalized_solution_spec": None,
1819
+ "stage_results": {},
1820
+ "stage_statuses": {},
1821
+ "artifacts": {},
1822
+ "status": "failed",
1823
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1824
+ "verification": None,
1825
+ "build_summary": None,
1826
+ "build_path": None,
1827
+ **error_fields,
1828
+ }
1829
+
1830
+ def _load_generated_app_spec(self, build_id: str) -> dict[str, Any] | None:
1831
+ """执行内部辅助逻辑。"""
1832
+ if not build_id:
1833
+ return None
1834
+ try:
1835
+ assembly = BuildAssemblyStore.open(build_id=build_id, create=False)
1836
+ except FileNotFoundError:
1837
+ return None
1838
+ generated_specs = assembly.get_builder_value("generated_specs", {})
1839
+ if not isinstance(generated_specs, dict):
1840
+ return None
1841
+ app_spec = generated_specs.get("app")
1842
+ return deepcopy(app_spec) if isinstance(app_spec, dict) else None
1843
+
1844
+ def _persist_generated_app_spec(
1845
+ self,
1846
+ *,
1847
+ build_id: str,
1848
+ generated: GeneratedAppBuild,
1849
+ package_resolution: dict[str, Any],
1850
+ title: str,
1851
+ requirement_text: str,
1852
+ layout_style: str,
1853
+ ) -> None:
1854
+ """执行内部辅助逻辑。"""
1855
+ assembly = BuildAssemblyStore.open(build_id=build_id)
1856
+ builder_state = assembly.get_builder_state()
1857
+ generated_specs = builder_state.get("generated_specs", {})
1858
+ if not isinstance(generated_specs, dict):
1859
+ generated_specs = {}
1860
+ generated_specs["app"] = deepcopy(generated.app_spec)
1861
+ generated_specs["app_summary"] = deepcopy(generated.summary)
1862
+ generated_specs["requirements_context"] = {
1863
+ "title": title,
1864
+ "requirement_text": requirement_text,
1865
+ "layout_style": layout_style,
1866
+ "package_resolution": deepcopy(package_resolution),
1867
+ }
1868
+ builder_state["generated_specs"] = generated_specs
1869
+ assembly.set_builder_state(builder_state)
1870
+
1871
+ def _record_failure_signature(
1872
+ self,
1873
+ *,
1874
+ assembly: BuildAssemblyStore,
1875
+ stage_name: str,
1876
+ stage_payload: dict[str, Any],
1877
+ errors: list[dict[str, Any]],
1878
+ ) -> tuple[str | None, int]:
1879
+ """执行内部辅助逻辑。"""
1880
+ signature = _failure_signature_for_stage(stage_name=stage_name, stage_payload=stage_payload, errors=errors)
1881
+ if signature is None:
1882
+ return None, 0
1883
+ builder_state = assembly.get_builder_state()
1884
+ signature_counts = builder_state.get("failure_signatures", {})
1885
+ if not isinstance(signature_counts, dict):
1886
+ signature_counts = {}
1887
+ count = int(signature_counts.get(signature, 0)) + 1
1888
+ signature_counts[signature] = count
1889
+ builder_state["failure_signatures"] = signature_counts
1890
+ builder_state["last_failure_signature"] = signature
1891
+ builder_state["last_failure_stage"] = stage_name
1892
+ builder_state["last_failure_count"] = count
1893
+ assembly.set_builder_state(builder_state)
1894
+ return signature, count
1895
+
1896
+ def _resolve_builder_package_reference(
1897
+ self,
1898
+ *,
1899
+ profile: str,
1900
+ package_tag_id: int,
1901
+ package_name: str,
1902
+ ) -> dict[str, Any]:
1903
+ """执行内部辅助逻辑。"""
1904
+ packages = PackageTools(self.sessions, self.backend)
1905
+ normalized_name = package_name.strip()
1906
+ if package_tag_id > 0:
1907
+ try:
1908
+ result = packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=False)
1909
+ except (QingflowApiError, RuntimeError) as exc:
1910
+ error = _coerce_solution_api_error(exc)
1911
+ if error.backend_code in {40002, 40027}:
1912
+ return {
1913
+ "status": "resolved",
1914
+ "matched_via": "tag_id",
1915
+ "tag_id": package_tag_id,
1916
+ "tag_name": None,
1917
+ "candidates": [],
1918
+ "metadata_unverified": True,
1919
+ "lookup_permission_blocked": {
1920
+ "scope": "package",
1921
+ "target": {"tag_id": package_tag_id},
1922
+ "transport_error": {
1923
+ "http_status": error.http_status,
1924
+ "backend_code": error.backend_code,
1925
+ "category": error.category,
1926
+ "request_id": error.request_id,
1927
+ },
1928
+ },
1929
+ }
1930
+ return _builder_package_resolution_failed(
1931
+ package_name=normalized_name,
1932
+ package_tag_id=package_tag_id,
1933
+ error=error,
1934
+ retried=False,
1935
+ )
1936
+ summary = result.get("result") if isinstance(result.get("result"), dict) else {}
1937
+ return {
1938
+ "status": "resolved",
1939
+ "matched_via": "tag_id",
1940
+ "tag_id": package_tag_id,
1941
+ "tag_name": summary.get("tagName"),
1942
+ "candidates": [],
1943
+ }
1944
+ if not normalized_name:
1945
+ return {
1946
+ "status": "new_package",
1947
+ "matched_via": "none",
1948
+ "tag_id": None,
1949
+ "tag_name": None,
1950
+ "candidates": [],
1951
+ "source_shape": None,
1952
+ "retried": False,
1953
+ }
1954
+ try:
1955
+ result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1956
+ except (QingflowApiError, RuntimeError) as exc:
1957
+ return _builder_package_resolution_failed(
1958
+ package_name=normalized_name,
1959
+ package_tag_id=package_tag_id,
1960
+ error=_coerce_solution_api_error(exc),
1961
+ retried=False,
1962
+ )
1963
+ items = result.get("items") if isinstance(result.get("items"), list) else []
1964
+ source_shape = result.get("source_shape")
1965
+ retried = False
1966
+ if not items:
1967
+ try:
1968
+ retry_result = packages.package_list(profile=profile, trial_status="all", include_raw=False)
1969
+ except (QingflowApiError, RuntimeError) as exc:
1970
+ return _builder_package_resolution_failed(
1971
+ package_name=normalized_name,
1972
+ package_tag_id=package_tag_id,
1973
+ error=_coerce_solution_api_error(exc),
1974
+ retried=True,
1975
+ )
1976
+ retry_items = retry_result.get("items") if isinstance(retry_result.get("items"), list) else []
1977
+ if retry_items:
1978
+ items = retry_items
1979
+ source_shape = retry_result.get("source_shape")
1980
+ retried = True
1981
+ exact = [
1982
+ item
1983
+ for item in items
1984
+ if isinstance(item, dict) and str(item.get("tagName") or "").strip().casefold() == normalized_name.casefold()
1985
+ ]
1986
+ fuzzy_candidates = [
1987
+ item
1988
+ for item in items
1989
+ if isinstance(item, dict) and normalized_name.casefold() in str(item.get("tagName") or "").strip().casefold()
1990
+ ]
1991
+ if len(exact) == 1:
1992
+ match = exact[0]
1993
+ return {
1994
+ "status": "resolved",
1995
+ "matched_via": "exact",
1996
+ "tag_id": match.get("tagId"),
1997
+ "tag_name": match.get("tagName"),
1998
+ "candidates": [],
1999
+ "source_shape": source_shape,
2000
+ "retried": retried,
2001
+ }
2002
+ candidates = [
2003
+ {"tag_id": item.get("tagId"), "tag_name": item.get("tagName")}
2004
+ for item in (exact or fuzzy_candidates)[:10]
2005
+ if isinstance(item, dict)
2006
+ ]
2007
+ detail = f"package_name '{normalized_name}' did not resolve uniquely by exact name"
2008
+ error_code = "AMBIGUOUS_PACKAGE"
2009
+ if not exact and not fuzzy_candidates:
2010
+ detail = f"package_name '{normalized_name}' was not found"
2011
+ error_code = "PACKAGE_NOT_FOUND"
2012
+ error_fields = _solution_error_fields(
2013
+ category="config",
2014
+ detail=detail,
2015
+ suggested_next_call=None,
2016
+ stage="app",
2017
+ )
2018
+ return {
2019
+ "status": "failed",
2020
+ "response": {
2021
+ "status": "failed",
2022
+ "mode": "plan",
2023
+ "stage": "app",
2024
+ "errors": [{"category": "config", "detail": detail}],
2025
+ "error_code": error_code,
2026
+ "recoverable": True,
2027
+ "missing_required_fields": error_fields["missing_required_fields"],
2028
+ "unknown_fields": error_fields["unknown_fields"],
2029
+ "invalid_field_types": error_fields["invalid_field_types"],
2030
+ "package_resolution": {
2031
+ "status": "failed",
2032
+ "requested_name": normalized_name,
2033
+ "resolution_policy": "exact_name_only",
2034
+ "candidates": candidates,
2035
+ "source_shape": source_shape,
2036
+ "retried": retried,
2037
+ },
2038
+ "suggested_next_call": {
2039
+ "tool_name": "solution_build_app_from_requirements",
2040
+ "arguments": {"mode": "plan", "title": "", "requirement_text": "", "package_name": normalized_name},
2041
+ },
2042
+ },
2043
+ }
2044
+
2045
+ def _resolve_builder_existing_app_reference(
2046
+ self,
2047
+ *,
2048
+ profile: str,
2049
+ app_key: str,
2050
+ package_resolution: dict[str, Any],
2051
+ update_only: bool,
2052
+ ) -> dict[str, Any]:
2053
+ """执行内部辅助逻辑。"""
2054
+ normalized_app_key = str(app_key or "").strip()
2055
+ if not normalized_app_key:
2056
+ if update_only:
2057
+ detail = "update target requires app_key"
2058
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2059
+ error_fields["missing_required_fields"] = sorted(
2060
+ dict.fromkeys(list(error_fields.get("missing_required_fields", [])) + ["app_key"])
2061
+ )
2062
+ error_fields["suggested_schema_call"] = None
2063
+ return {
2064
+ "status": "failed",
2065
+ "response": {
2066
+ "status": "failed",
2067
+ "stage": "app",
2068
+ "errors": [{"category": "config", "detail": detail}],
2069
+ **error_fields,
2070
+ },
2071
+ }
2072
+ return {"status": "none"}
2073
+
2074
+ app_tools = AppTools(self.sessions, self.backend)
2075
+ try:
2076
+ base_info = app_tools.app_get_base(profile=profile, app_key=normalized_app_key, include_raw=True).get("result") or {}
2077
+ except Exception as exc: # noqa: BLE001
2078
+ detail = f"failed to resolve existing app '{normalized_app_key}': {exc}"
2079
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2080
+ error_fields["suggested_schema_call"] = None
2081
+ return {
2082
+ "status": "failed",
2083
+ "response": {
2084
+ "status": "failed",
2085
+ "stage": "app",
2086
+ "errors": [{"category": "config", "detail": detail}],
2087
+ **error_fields,
2088
+ },
2089
+ }
2090
+
2091
+ raw_tag_ids = base_info.get("tagIds") if isinstance(base_info.get("tagIds"), list) else []
2092
+ tag_ids = [tag_id for tag_id in (_coerce_count(item) for item in raw_tag_ids) if tag_id is not None]
2093
+ effective_package_resolution = deepcopy(package_resolution)
2094
+ if package_resolution.get("status") == "resolved":
2095
+ resolved_tag_id = _coerce_count(package_resolution.get("tag_id"))
2096
+ if resolved_tag_id is not None and tag_ids and resolved_tag_id not in tag_ids:
2097
+ detail = (
2098
+ f"app_key '{normalized_app_key}' does not belong to requested package "
2099
+ f"'{package_resolution.get('tag_name') or resolved_tag_id}'"
2100
+ )
2101
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2102
+ error_fields["error_code"] = "APP_PACKAGE_MISMATCH"
2103
+ error_fields["suggested_schema_call"] = None
2104
+ return {
2105
+ "status": "failed",
2106
+ "response": {
2107
+ "status": "failed",
2108
+ "stage": "app",
2109
+ "errors": [{"category": "config", "detail": detail}],
2110
+ "existing_app": {
2111
+ "app_key": normalized_app_key,
2112
+ "app_name": base_info.get("formTitle"),
2113
+ "tag_ids": tag_ids,
2114
+ },
2115
+ **error_fields,
2116
+ },
2117
+ }
2118
+ elif package_resolution.get("status") == "new_package":
2119
+ if len(tag_ids) == 1:
2120
+ package_tools = PackageTools(self.sessions, self.backend)
2121
+ package_detail = package_tools.package_get(profile=profile, tag_id=tag_ids[0], include_raw=False).get("result") or {}
2122
+ effective_package_resolution = {
2123
+ "status": "resolved",
2124
+ "matched_via": "existing_app",
2125
+ "tag_id": tag_ids[0],
2126
+ "tag_name": package_detail.get("tagName"),
2127
+ "candidates": [],
2128
+ }
2129
+ elif len(tag_ids) > 1:
2130
+ detail = f"app_key '{normalized_app_key}' belongs to multiple packages; provide package_tag_id or package_name explicitly"
2131
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2132
+ error_fields["error_code"] = "AMBIGUOUS_PACKAGE"
2133
+ error_fields["suggested_schema_call"] = None
2134
+ return {
2135
+ "status": "failed",
2136
+ "response": {
2137
+ "status": "failed",
2138
+ "stage": "app",
2139
+ "errors": [{"category": "config", "detail": detail}],
2140
+ "existing_app": {
2141
+ "app_key": normalized_app_key,
2142
+ "app_name": base_info.get("formTitle"),
2143
+ "tag_ids": tag_ids,
2144
+ },
2145
+ **error_fields,
2146
+ },
2147
+ }
2148
+ return {
2149
+ "status": "resolved",
2150
+ "app_key": normalized_app_key,
2151
+ "app_name": base_info.get("formTitle"),
2152
+ "package_resolution": effective_package_resolution,
2153
+ "tag_ids": tag_ids,
2154
+ }
2155
+
2156
+ def _requirements_failure_response(
2157
+ self,
2158
+ *,
2159
+ mode: str,
2160
+ build_id: str,
2161
+ title: str,
2162
+ requirement_text: str,
2163
+ package_resolution: dict[str, Any],
2164
+ app_key: str,
2165
+ update_only: bool,
2166
+ layout_style: str,
2167
+ publish: bool,
2168
+ run_label: str | None,
2169
+ detail: str,
2170
+ error_code: str | None = None,
2171
+ recoverable: bool | None = None,
2172
+ missing_required_fields: list[str] | None = None,
2173
+ invalid_field_types: list[str] | None = None,
2174
+ error_details: dict[str, Any] | None = None,
2175
+ ) -> dict[str, Any]:
2176
+ """执行内部辅助逻辑。"""
2177
+ suggested_next_call = (
2178
+ {"tool_name": "solution_build_app", "arguments": {"mode": "plan", "build_id": build_id}}
2179
+ if build_id and self._load_generated_app_spec(build_id)
2180
+ else _requirements_next_call(
2181
+ next_mode="plan" if mode in {"", "preflight"} else mode,
2182
+ build_id=build_id or None,
2183
+ title=title,
2184
+ requirement_text=requirement_text,
2185
+ package_resolution=package_resolution,
2186
+ app_key=app_key,
2187
+ update_only=update_only,
2188
+ layout_style=layout_style,
2189
+ publish=publish,
2190
+ run_label=run_label,
2191
+ )
2192
+ )
2193
+ error_fields = _solution_error_fields(
2194
+ category="config",
2195
+ detail=detail,
2196
+ suggested_next_call=suggested_next_call,
2197
+ stage="app",
2198
+ )
2199
+ if error_code:
2200
+ error_fields["error_code"] = error_code
2201
+ if recoverable is not None:
2202
+ error_fields["recoverable"] = recoverable
2203
+ if missing_required_fields:
2204
+ error_fields["missing_required_fields"] = sorted(
2205
+ dict.fromkeys(list(error_fields.get("missing_required_fields", [])) + list(missing_required_fields))
2206
+ )
2207
+ if invalid_field_types:
2208
+ error_fields["invalid_field_types"] = sorted(
2209
+ dict.fromkeys(list(error_fields.get("invalid_field_types", [])) + list(invalid_field_types))
2210
+ )
2211
+ error_fields["suggested_schema_call"] = None
2212
+ return {
2213
+ "status": "failed",
2214
+ "stage": "app",
2215
+ "mode": mode,
2216
+ "build_id": build_id,
2217
+ "errors": [{"category": "config", "detail": detail}],
2218
+ "package_resolution": package_resolution,
2219
+ "next_strategy": "switch_to_explicit_app_stage" if build_id and self._load_generated_app_spec(build_id) else "refine_requirements",
2220
+ "failure_signature": None,
2221
+ "stage_failure_count": 0,
2222
+ "details": deepcopy(error_details or {}),
2223
+ "suggested_requirement_hint": (error_details or {}).get("suggested_requirement_hint") if isinstance(error_details, dict) else None,
2224
+ "suggested_patch": deepcopy((error_details or {}).get("suggested_patch")) if isinstance(error_details, dict) else None,
2225
+ **error_fields,
2226
+ }
2227
+
2228
+ def _requirements_success_response(
2229
+ self,
2230
+ *,
2231
+ mode: str,
2232
+ title: str,
2233
+ requirement_text: str,
2234
+ package_resolution: dict[str, Any],
2235
+ layout_style: str,
2236
+ publish: bool,
2237
+ run_label: str | None,
2238
+ generated: GeneratedAppBuild,
2239
+ stage_result: dict[str, Any],
2240
+ include_generated_spec: bool,
2241
+ ) -> dict[str, Any]:
2242
+ """执行内部辅助逻辑。"""
2243
+ next_call = stage_result.get("suggested_next_call")
2244
+ response = {
2245
+ "status": stage_result.get("status"),
2246
+ "stage": "app",
2247
+ "mode": mode,
2248
+ "build_id": stage_result.get("build_id"),
2249
+ "build_path": stage_result.get("build_path"),
2250
+ "run_path": stage_result.get("run_path"),
2251
+ "artifacts": stage_result.get("artifacts", {}),
2252
+ "errors": stage_result.get("errors", []),
2253
+ "package_resolution": package_resolution,
2254
+ "generated_app_summary": generated.summary,
2255
+ "resolved_layout_style": generated.summary.get("resolved_layout_style"),
2256
+ "field_selection_policy": generated.summary.get("field_selection_policy"),
2257
+ "excluded_advanced_fields": generated.summary.get("excluded_advanced_fields"),
2258
+ "execution_step_count": len((stage_result.get("execution_plan") or {}).get("steps", [])),
2259
+ "suggested_next_call": next_call,
2260
+ "views_strategy": _resolve_app_stage_views_strategy(stage_result),
2261
+ }
2262
+ if isinstance(stage_result.get("verification"), dict):
2263
+ verification = stage_result["verification"]
2264
+ response["verification"] = verification
2265
+ if "package_attached" in verification:
2266
+ response["package_attached"] = verification.get("package_attached")
2267
+ if isinstance(verification.get("apps"), list):
2268
+ response["tag_ids_after"] = {
2269
+ str(item.get("entity_id")): item.get("tag_ids_after")
2270
+ for item in verification["apps"]
2271
+ if isinstance(item, dict) and item.get("entity_id") is not None
2272
+ }
2273
+ if isinstance(verification.get("views_created"), list):
2274
+ response["views_created"] = verification.get("views_created")
2275
+ for key in (
2276
+ "error_code",
2277
+ "recoverable",
2278
+ "missing_required_fields",
2279
+ "unknown_fields",
2280
+ "invalid_field_types",
2281
+ "invalid_enum_values",
2282
+ "next_strategy",
2283
+ "failure_signature",
2284
+ "stage_failure_count",
2285
+ ):
2286
+ if key in stage_result:
2287
+ response[key] = stage_result[key]
2288
+ if include_generated_spec:
2289
+ response["generated_app_spec"] = generated.app_spec
2290
+ return response
2291
+
2292
+
2293
+ def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
2294
+ merged = deepcopy(base)
2295
+ for key, value in patch.items():
2296
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
2297
+ merged[key] = deep_merge(merged[key], value)
2298
+ else:
2299
+ merged[key] = deepcopy(value)
2300
+ return merged
2301
+
2302
+
2303
+ def _extract_publish_results(step_results: dict[str, Any]) -> dict[str, Any]:
2304
+ return {
2305
+ step_name: step_result
2306
+ for step_name, step_result in step_results.items()
2307
+ if step_name.startswith("publish.")
2308
+ }
2309
+
2310
+
2311
+ def _sanitize_json_value(value: Any) -> Any:
2312
+ if value is None or isinstance(value, (str, int, float, bool)):
2313
+ return value
2314
+ if isinstance(value, BaseModel):
2315
+ return _sanitize_json_value(value.model_dump(mode="json"))
2316
+ if isinstance(value, dict):
2317
+ return {str(key): _sanitize_json_value(item) for key, item in value.items()}
2318
+ if isinstance(value, (list, tuple, set)):
2319
+ return [_sanitize_json_value(item) for item in value]
2320
+ if isinstance(value, Exception):
2321
+ return str(value)
2322
+ if hasattr(value, "__fspath__"):
2323
+ return str(value)
2324
+ return str(value)
2325
+
2326
+
2327
+ def _sanitize_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
2328
+ sanitized: list[dict[str, Any]] = []
2329
+ for error in errors:
2330
+ payload = _sanitize_json_value(error)
2331
+ if isinstance(payload, dict):
2332
+ sanitized.append(payload)
2333
+ continue
2334
+ sanitized.append({"category": "runtime", "detail": payload})
2335
+ return sanitized
2336
+
2337
+
2338
+ def _generate_build_id(*, run_label: str | None, stage_name: str) -> str:
2339
+ prefix = _slug_fragment(run_label) or stage_name
2340
+ return f"{prefix}-{uuid4().hex[:10]}"
2341
+
2342
+
2343
+ def _generate_session_id(*, metadata: dict[str, Any], stage: str | None) -> str:
2344
+ base = _slug_fragment(str(metadata.get("goal") or metadata.get("task") or metadata.get("run_label") or "")) or stage or "design"
2345
+ return f"{base}-{uuid4().hex[:10]}"
2346
+
2347
+
2348
+ def _slug_fragment(value: str | None) -> str:
2349
+ if not value:
2350
+ return ""
2351
+ text = "".join(ch.lower() if ch.isalnum() else "-" for ch in value).strip("-")
2352
+ compact = "-".join(part for part in text.split("-") if part)
2353
+ return compact[:32].rstrip("-")
2354
+
2355
+
2356
+ def _solution_build_next_call(*, tool_name: str, mode: str, build_id: str | None) -> dict[str, Any] | None:
2357
+ if not build_id:
2358
+ return None
2359
+ if mode not in STAGED_BUILD_MODES:
2360
+ return None
2361
+ if mode == "preflight":
2362
+ next_mode = "plan"
2363
+ elif mode == "plan":
2364
+ next_mode = "apply"
2365
+ else:
2366
+ next_mode = "repair"
2367
+ return {"tool_name": tool_name, "arguments": {"mode": next_mode, "build_id": build_id}}
2368
+
2369
+
2370
+ def _normalize_staged_build_mode(mode: str) -> str:
2371
+ normalized = str(mode or "").strip().lower()
2372
+ if not normalized:
2373
+ return normalized
2374
+ return STAGED_BUILD_MODE_ALIASES.get(normalized, normalized)
2375
+
2376
+
2377
+ def _requirements_next_call(
2378
+ *,
2379
+ next_mode: str,
2380
+ build_id: str | None,
2381
+ title: str,
2382
+ requirement_text: str,
2383
+ package_resolution: dict[str, Any],
2384
+ app_key: str,
2385
+ update_only: bool,
2386
+ layout_style: str,
2387
+ publish: bool,
2388
+ run_label: str | None,
2389
+ ) -> dict[str, Any]:
2390
+ arguments: dict[str, Any] = {
2391
+ "mode": next_mode,
2392
+ "title": title,
2393
+ "requirement_text": requirement_text,
2394
+ "layout_style": layout_style,
2395
+ "publish": publish,
2396
+ }
2397
+ if build_id:
2398
+ arguments["build_id"] = build_id
2399
+ if run_label:
2400
+ arguments["run_label"] = run_label
2401
+ if isinstance(package_resolution.get("tag_id"), int) and package_resolution["tag_id"] > 0:
2402
+ arguments["package_tag_id"] = package_resolution["tag_id"]
2403
+ elif isinstance(package_resolution.get("tag_name"), str) and package_resolution["tag_name"]:
2404
+ arguments["package_name"] = package_resolution["tag_name"]
2405
+ if isinstance(app_key, str) and app_key:
2406
+ arguments["app_key"] = app_key
2407
+ if update_only or arguments.get("app_key"):
2408
+ arguments["update_only"] = True
2409
+ return {"tool_name": "solution_build_app_from_requirements", "arguments": arguments}
2410
+
2411
+
2412
+ def _rewrite_requirements_next_call(
2413
+ *,
2414
+ result_next_call: dict[str, Any] | None,
2415
+ title: str,
2416
+ requirement_text: str,
2417
+ package_resolution: dict[str, Any],
2418
+ app_key: str,
2419
+ update_only: bool,
2420
+ layout_style: str,
2421
+ publish: bool,
2422
+ run_label: str | None,
2423
+ ) -> dict[str, Any] | None:
2424
+ if not isinstance(result_next_call, dict):
2425
+ return None
2426
+ arguments = result_next_call.get("arguments")
2427
+ if not isinstance(arguments, dict):
2428
+ return None
2429
+ next_mode = arguments.get("mode")
2430
+ if not isinstance(next_mode, str) or not next_mode:
2431
+ return None
2432
+ build_id = arguments.get("build_id")
2433
+ return _requirements_next_call(
2434
+ next_mode=next_mode,
2435
+ build_id=build_id if isinstance(build_id, str) else None,
2436
+ title=title,
2437
+ requirement_text=requirement_text,
2438
+ package_resolution=package_resolution,
2439
+ app_key=app_key,
2440
+ update_only=update_only,
2441
+ layout_style=layout_style,
2442
+ publish=publish,
2443
+ run_label=run_label,
2444
+ )
2445
+
2446
+
2447
+ def _requirements_target(
2448
+ package_resolution: dict[str, Any],
2449
+ *,
2450
+ app_key: str = "",
2451
+ update_only: bool = False,
2452
+ ) -> dict[str, Any]:
2453
+ tag_id = package_resolution.get("tag_id")
2454
+ target: dict[str, Any] = {}
2455
+ if isinstance(tag_id, int) and tag_id > 0:
2456
+ target["package_tag_id"] = tag_id
2457
+ if isinstance(app_key, str) and app_key:
2458
+ target["app_key"] = app_key
2459
+ if update_only or target.get("app_key"):
2460
+ target["mode"] = "update"
2461
+ target["update_only"] = True
2462
+ return target
2463
+
2464
+
2465
+ def _merge_app_target(
2466
+ target: dict[str, Any] | None,
2467
+ *,
2468
+ app_key: str,
2469
+ package_tag_id: int,
2470
+ update_only: bool,
2471
+ ) -> dict[str, Any]:
2472
+ merged = deepcopy(target) if isinstance(target, dict) else {}
2473
+ if isinstance(app_key, str) and app_key.strip():
2474
+ merged["app_key"] = app_key.strip()
2475
+ if isinstance(package_tag_id, int) and package_tag_id > 0:
2476
+ merged["package_tag_id"] = package_tag_id
2477
+ if update_only or merged.get("app_key"):
2478
+ merged["mode"] = "update"
2479
+ merged["update_only"] = True
2480
+ return merged
2481
+
2482
+
2483
+ def _target_is_update_only(target: dict[str, Any]) -> bool:
2484
+ if not target:
2485
+ return False
2486
+ if target.get("update_only") is True:
2487
+ return True
2488
+ mode = target.get("mode")
2489
+ return isinstance(mode, str) and mode.strip().lower() == "update"
2490
+
2491
+
2492
+ def _resolve_target_app_keys(*, target: dict[str, Any], entity_ids: list[str]) -> dict[str, str]:
2493
+ if not target:
2494
+ return {}
2495
+ resolved: dict[str, str] = {}
2496
+ direct_app_key = target.get("app_key")
2497
+ if isinstance(direct_app_key, str) and direct_app_key.strip():
2498
+ if len(entity_ids) != 1:
2499
+ raise ValueError("target.app_key requires a single-entity app stage; use target.app_keys for multiple entities")
2500
+ resolved[entity_ids[0]] = direct_app_key.strip()
2501
+ raw_mapping = target.get("app_keys")
2502
+ if isinstance(raw_mapping, dict):
2503
+ for entity_id, app_key in raw_mapping.items():
2504
+ if not isinstance(entity_id, str) or entity_id not in entity_ids:
2505
+ raise ValueError(f"target.app_keys contains unknown entity '{entity_id}'")
2506
+ if not isinstance(app_key, str) or not app_key.strip():
2507
+ raise ValueError(f"target.app_keys['{entity_id}'] must be a non-empty string")
2508
+ resolved[entity_id] = app_key.strip()
2509
+ return resolved
2510
+
2511
+
2512
+ def _seed_existing_app_artifacts(*, assembly: BuildAssemblyStore, existing_app_targets: dict[str, str]) -> None:
2513
+ if not existing_app_targets:
2514
+ return
2515
+ artifacts = assembly.get_artifacts()
2516
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
2517
+ for entity_id, app_key in existing_app_targets.items():
2518
+ existing = apps_artifact.get(entity_id, {}) if isinstance(apps_artifact.get(entity_id), dict) else {}
2519
+ next_artifact = deepcopy(existing)
2520
+ next_artifact["app_key"] = app_key
2521
+ next_artifact["reused"] = True
2522
+ next_artifact["target_mode"] = "update"
2523
+ apps_artifact[entity_id] = next_artifact
2524
+ artifacts["apps"] = apps_artifact
2525
+ assembly.set_artifacts(artifacts)
2526
+
2527
+
2528
+ def _design_session_next_call(*, action: str, session_id: str | None) -> dict[str, Any] | None:
2529
+ if action == "start" and not session_id:
2530
+ return {"tool_name": "solution_design_session", "arguments": {"action": "start"}}
2531
+ if session_id:
2532
+ return {"tool_name": "solution_design_session", "arguments": {"action": "get", "session_id": session_id}}
2533
+ return None
2534
+
2535
+
2536
+ def _design_summary_next_call(summary: dict[str, Any]) -> dict[str, Any] | None:
2537
+ session_id = summary.get("session_id")
2538
+ if not session_id:
2539
+ return None
2540
+ if summary.get("status") == "finalized":
2541
+ return None
2542
+ next_stage = summary.get("next_stage")
2543
+ if next_stage == "finalize":
2544
+ return {"tool_name": "solution_design_session", "arguments": {"action": "finalize", "session_id": session_id}}
2545
+ if next_stage:
2546
+ return {
2547
+ "tool_name": "solution_design_session",
2548
+ "arguments": {"action": "submit_stage", "session_id": session_id, "stage": next_stage},
2549
+ }
2550
+ return {"tool_name": "solution_design_session", "arguments": {"action": "get", "session_id": session_id}}
2551
+
2552
+
2553
+ def _normalize_solution_schema_stage(stage: str) -> str | None:
2554
+ normalized = str(stage or "").strip()
2555
+ if not normalized:
2556
+ return None
2557
+ return SOLUTION_SCHEMA_STAGE_ALIASES.get(normalized.lower()) or SOLUTION_SCHEMA_STAGE_ALIASES.get(normalized)
2558
+
2559
+
2560
+ def _normalize_solution_schema_intent(intent: str) -> str | None:
2561
+ normalized = str(intent or "").strip()
2562
+ if not normalized:
2563
+ return None
2564
+ return SOLUTION_SCHEMA_INTENT_ALIASES.get(normalized.lower()) or SOLUTION_SCHEMA_INTENT_ALIASES.get(normalized)
2565
+
2566
+
2567
+ def _solution_schema_example_payload(
2568
+ *,
2569
+ stage: str,
2570
+ intent: str,
2571
+ include_examples: bool = False,
2572
+ include_selected_example: bool = False,
2573
+ ) -> dict[str, Any]:
2574
+ tool_name, payload_key = _solution_schema_tool_metadata(stage)
2575
+ examples = _solution_schema_examples(stage)
2576
+ selected = deepcopy(examples.get(intent) or examples["minimal"])
2577
+ call_example = _solution_schema_call_example(stage)
2578
+ response = {
2579
+ "status": "ok",
2580
+ "stage": stage,
2581
+ "intent": intent,
2582
+ "tool_name": tool_name,
2583
+ "payload_key": payload_key,
2584
+ "available_intents": sorted(examples.keys()),
2585
+ "required_fields": _solution_schema_required_fields(stage),
2586
+ "optional_fields": _solution_schema_optional_fields(stage),
2587
+ "common_errors": _solution_schema_common_errors(stage),
2588
+ "notes": _solution_schema_notes(stage),
2589
+ "call_example": call_example,
2590
+ "suggested_next_call": deepcopy(call_example),
2591
+ }
2592
+ if include_selected_example:
2593
+ response["selected_example"] = selected
2594
+ if include_examples:
2595
+ response["examples"] = examples
2596
+ return response
2597
+
2598
+
2599
+ def _solution_schema_tool_metadata(stage: str) -> tuple[str, str]:
2600
+ mapping = {
2601
+ "app": ("solution_build_app", "app_spec"),
2602
+ "app_update": ("solution_build_app", "app_spec"),
2603
+ "flow": ("solution_build_flow", "flow_spec"),
2604
+ "views": ("solution_build_views", "views_spec"),
2605
+ "analytics_portal": ("solution_build_analytics_portal", "analytics_portal_spec"),
2606
+ "navigation": ("solution_build_navigation", "navigation_spec"),
2607
+ "app_flow": ("solution_build_app_flow", "app_flow_spec"),
2608
+ "all": ("solution_build_all", "solution_spec"),
2609
+ }
2610
+ return mapping[stage]
2611
+
2612
+
2613
+ def _solution_schema_call_example(stage: str) -> dict[str, Any]:
2614
+ tool_name, _payload_key = _solution_schema_tool_metadata(stage)
2615
+ arguments: dict[str, Any] = {"mode": "preflight"}
2616
+ if stage == "app_update":
2617
+ arguments.update({"app_key": "APP_123456", "update_only": True})
2618
+ return {"tool_name": tool_name, "arguments": arguments}
2619
+
2620
+
2621
+ def _solution_schema_required_fields(stage: str) -> list[str]:
2622
+ fields = {
2623
+ "app": ["solution_name", "entities[].entity_id", "entities[].display_name", "entities[].kind", "entities[].fields[]"],
2624
+ "app_update": ["solution_name", "entities[].entity_id", "entities[].display_name", "entities[].kind", "entities[].fields[]", "app_key"],
2625
+ "flow": ["entities[].entity_id", "entities[].workflow or entities[].lifecycle_stages"],
2626
+ "views": ["entities[].entity_id", "entities[].views[]"],
2627
+ "analytics_portal": ["entities[].entity_id or portal.sections[]"],
2628
+ "navigation": ["navigation.items[]"],
2629
+ "app_flow": ["solution_name", "entities[]"],
2630
+ "all": ["solution_name", "entities[]"],
2631
+ }
2632
+ return fields[stage]
2633
+
2634
+
2635
+ def _solution_schema_optional_fields(stage: str) -> list[str]:
2636
+ fields = {
2637
+ "app": ["summary", "business_context", "package", "roles", "publish_policy", "preferences", "entities[].form_layout", "entities[].sample_records"],
2638
+ "app_update": ["package_tag_id", "publish_policy", "preferences", "entities[].form_layout", "entities[].sample_records", "update_only"],
2639
+ "flow": ["solution_name", "roles", "publish_policy", "entities[].status_field_id", "entities[].owner_field_id", "entities[].start_field_id", "entities[].end_field_id"],
2640
+ "views": ["solution_name", "metadata"],
2641
+ "analytics_portal": ["solution_name", "publish_policy", "portal.name", "portal.sections[]"],
2642
+ "navigation": ["solution_name", "publish_policy", "navigation.enabled"],
2643
+ "app_flow": ["summary", "package", "roles", "publish_policy", "preferences"],
2644
+ "all": ["portal", "navigation", "roles", "publish_policy", "preferences", "business_context"],
2645
+ }
2646
+ return fields[stage]
2647
+
2648
+
2649
+ def _solution_schema_common_errors(stage: str) -> list[str]:
2650
+ errors = {
2651
+ "app": [
2652
+ "Use enum values exactly, for example field.type='text' or kind='transaction'.",
2653
+ "Each field_id must be unique within an entity.",
2654
+ "Do not include workflow/views/charts in app_spec. Use solution_build_flow or later stage tools.",
2655
+ ],
2656
+ "app_update": [
2657
+ "app_key is required for update mode and must point to an existing app.",
2658
+ "Updating an app uses the same app_spec structure, but the tool target must stay in update mode.",
2659
+ "Do not omit entity_id; updates still need a deterministic entity identity.",
2660
+ ],
2661
+ "flow": [
2662
+ "The target entity must already exist in the build manifest or from a prior solution_build_app run.",
2663
+ "status_field_id must point to an existing field, usually 'status'.",
2664
+ "Do not include fields or form_layout in flow_spec.",
2665
+ ],
2666
+ "views": [
2667
+ "View field_ids must reference existing fields on the entity.",
2668
+ "Run solution_build_app first so the entity exists.",
2669
+ ],
2670
+ "analytics_portal": [
2671
+ "Chart field references must already exist on the entity.",
2672
+ "Portal sections that reference views require solution_build_views to run first.",
2673
+ ],
2674
+ "navigation": [
2675
+ "Navigation must include a package target item as the single published root.",
2676
+ ],
2677
+ "app_flow": [
2678
+ "If app and flow are authored separately, prefer solution_build_app then solution_build_flow.",
2679
+ ],
2680
+ "all": [
2681
+ "Use full solution_spec only when the agent can provide all stages coherently.",
2682
+ ],
2683
+ }
2684
+ return errors[stage]
2685
+
2686
+
2687
+ def _solution_schema_notes(stage: str) -> list[str]:
2688
+ notes = {
2689
+ "app": [
2690
+ "Use solution_build_app for package, entity, field, layout, and sample record design.",
2691
+ "If you are unsure, call solution_schema_example(stage='app', intent='minimal') before generating payloads.",
2692
+ ],
2693
+ "app_update": [
2694
+ "Use solution_build_app with app_key and update_only=true when modifying an existing app.",
2695
+ "Update mode is not allowed to fall back to new package or new app creation.",
2696
+ ],
2697
+ "flow": [
2698
+ "Use solution_build_flow only after app/schema exists.",
2699
+ "workflow.nodes uses node_type enum values like start, audit, fill, copy, branch, condition.",
2700
+ ],
2701
+ "views": ["views_spec is purely for view definitions, not fields or workflow."],
2702
+ "analytics_portal": ["analytics_portal_spec may include charts, portal, or both."],
2703
+ "navigation": ["navigation_spec only manages top-level published navigation."],
2704
+ "app_flow": ["solution_build_app_flow remains available for compatibility, but app + flow split is preferred."],
2705
+ "all": ["solution_build_all is best for coherent multi-stage system builds."],
2706
+ }
2707
+ return notes[stage]
2708
+
2709
+
2710
+ def _solution_schema_examples(stage: str) -> dict[str, dict[str, Any]]:
2711
+ examples = {
2712
+ "app": {
2713
+ "minimal": {
2714
+ "solution_name": "Demo Workspace",
2715
+ "entities": [
2716
+ {
2717
+ "entity_id": "demo_request",
2718
+ "display_name": "测试申请",
2719
+ "kind": "transaction",
2720
+ "title_field_id": "title",
2721
+ "fields": [
2722
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
2723
+ ],
2724
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
2725
+ }
2726
+ ],
2727
+ },
2728
+ "full": {
2729
+ "solution_name": "Demo Workspace",
2730
+ "summary": "通用测试应用",
2731
+ "package": {"name": "测试包"},
2732
+ "entities": [
2733
+ {
2734
+ "entity_id": "demo_request",
2735
+ "display_name": "测试申请",
2736
+ "kind": "transaction",
2737
+ "title_field_id": "title",
2738
+ "status_field_id": "status",
2739
+ "fields": [
2740
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
2741
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
2742
+ {"field_id": "owner", "label": "负责人", "type": "member"},
2743
+ {"field_id": "amount", "label": "金额", "type": "amount"},
2744
+ ],
2745
+ "form_layout": {
2746
+ "sections": [
2747
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "status"]}, {"field_ids": ["owner", "amount"]}]}
2748
+ ]
2749
+ },
2750
+ "sample_records": [{"record_id": "demo-1", "values": {"title": "测试记录", "status": "待处理", "amount": 1000}}],
2751
+ }
2752
+ ],
2753
+ },
2754
+ "demo": {
2755
+ "solution_name": "全字段演示",
2756
+ "package": {"name": "字段演示"},
2757
+ "entities": [
2758
+ {
2759
+ "entity_id": "field_demo",
2760
+ "display_name": "字段演示",
2761
+ "kind": "transaction",
2762
+ "title_field_id": "title",
2763
+ "fields": [
2764
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
2765
+ {"field_id": "description", "label": "说明", "type": "long_text"},
2766
+ {"field_id": "amount", "label": "金额", "type": "amount"},
2767
+ {"field_id": "due_at", "label": "截止时间", "type": "datetime"},
2768
+ {"field_id": "assignee", "label": "处理人", "type": "member"},
2769
+ {"field_id": "team", "label": "所属部门", "type": "department"},
2770
+ {"field_id": "category", "label": "类型", "type": "single_select", "options": ["A", "B", "C"]},
2771
+ {"field_id": "tags", "label": "标签", "type": "multi_select", "options": ["高优先级", "新建", "内部"]},
2772
+ {"field_id": "phone", "label": "手机号", "type": "phone"},
2773
+ {"field_id": "email", "label": "邮箱", "type": "email"},
2774
+ {"field_id": "attachment", "label": "附件", "type": "attachment"},
2775
+ {"field_id": "enabled", "label": "启用", "type": "boolean"},
2776
+ ],
2777
+ "form_layout": {
2778
+ "sections": [
2779
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "category"]}, {"field_ids": ["description"]}]},
2780
+ {"section_id": "coordination", "title": "协同", "rows": [{"field_ids": ["assignee", "team"]}, {"field_ids": ["due_at", "amount"]}]},
2781
+ {"section_id": "contact", "title": "联系与附件", "rows": [{"field_ids": ["phone", "email"]}, {"field_ids": ["attachment", "enabled"]}]},
2782
+ ]
2783
+ },
2784
+ }
2785
+ ],
2786
+ },
2787
+ },
2788
+ "app_update": {
2789
+ "minimal": {
2790
+ "solution_name": "Demo Workspace",
2791
+ "entities": [
2792
+ {
2793
+ "entity_id": "demo_request",
2794
+ "display_name": "测试申请",
2795
+ "kind": "transaction",
2796
+ "title_field_id": "title",
2797
+ "fields": [
2798
+ {"field_id": "title", "label": "工单标题", "type": "text", "required": True},
2799
+ ],
2800
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
2801
+ }
2802
+ ],
2803
+ },
2804
+ "full": {
2805
+ "solution_name": "Demo Workspace",
2806
+ "entities": [
2807
+ {
2808
+ "entity_id": "demo_request",
2809
+ "display_name": "测试申请",
2810
+ "kind": "transaction",
2811
+ "title_field_id": "title",
2812
+ "status_field_id": "status",
2813
+ "fields": [
2814
+ {"field_id": "title", "label": "工单标题", "type": "text", "required": True},
2815
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
2816
+ {"field_id": "priority", "label": "优先级", "type": "single_select", "options": ["P0", "P1", "P2"]},
2817
+ ],
2818
+ "form_layout": {
2819
+ "sections": [
2820
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "status"]}, {"field_ids": ["priority"]}]}
2821
+ ]
2822
+ },
2823
+ }
2824
+ ],
2825
+ },
2826
+ "demo": {
2827
+ "solution_name": "Demo Workspace",
2828
+ "entities": [
2829
+ {
2830
+ "entity_id": "customer_order",
2831
+ "display_name": "客户订单",
2832
+ "kind": "transaction",
2833
+ "title_field_id": "title",
2834
+ "fields": [
2835
+ {"field_id": "title", "label": "订单标题", "type": "text", "required": True},
2836
+ {"field_id": "customer_name", "label": "客户名称", "type": "text"},
2837
+ {"field_id": "amount", "label": "订单金额", "type": "amount"},
2838
+ ],
2839
+ "form_layout": {"rows": [{"field_ids": ["title", "customer_name"]}, {"field_ids": ["amount"]}]},
2840
+ }
2841
+ ],
2842
+ },
2843
+ },
2844
+ "flow": {
2845
+ "minimal": {
2846
+ "solution_name": "Demo Workspace",
2847
+ "entities": [
2848
+ {
2849
+ "entity_id": "demo_request",
2850
+ "status_field_id": "status",
2851
+ "workflow": {
2852
+ "enabled": True,
2853
+ "nodes": [
2854
+ {"node_id": "approve", "name": "审批", "node_type": "audit"},
2855
+ ],
2856
+ },
2857
+ }
2858
+ ],
2859
+ },
2860
+ "full": {
2861
+ "solution_name": "Demo Workspace",
2862
+ "roles": [{"role_id": "approver", "name": "审批人"}],
2863
+ "entities": [
2864
+ {
2865
+ "entity_id": "demo_request",
2866
+ "status_field_id": "status",
2867
+ "owner_field_id": "owner",
2868
+ "workflow": {
2869
+ "enabled": True,
2870
+ "nodes": [
2871
+ {"node_id": "start", "name": "发起", "node_type": "start"},
2872
+ {"node_id": "approve", "name": "直属审批", "node_type": "audit", "assignees": {"roles": ["approver"]}},
2873
+ ],
2874
+ },
2875
+ }
2876
+ ],
2877
+ },
2878
+ "demo": {
2879
+ "solution_name": "Demo Workspace",
2880
+ "entities": [
2881
+ {
2882
+ "entity_id": "field_demo",
2883
+ "status_field_id": "status",
2884
+ "workflow": {
2885
+ "enabled": True,
2886
+ "nodes": [
2887
+ {"node_id": "start", "name": "发起", "node_type": "start"},
2888
+ {"node_id": "manager_review", "name": "经理审批", "node_type": "audit"},
2889
+ {"node_id": "copy_notice", "name": "结果抄送", "node_type": "copy"},
2890
+ ],
2891
+ },
2892
+ }
2893
+ ],
2894
+ },
2895
+ },
2896
+ "views": {
2897
+ "minimal": {
2898
+ "solution_name": "Demo Workspace",
2899
+ "entities": [
2900
+ {"entity_id": "demo_request", "views": [{"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title"], "being_default": True}]}
2901
+ ],
2902
+ },
2903
+ "full": {
2904
+ "solution_name": "Demo Workspace",
2905
+ "entities": [
2906
+ {
2907
+ "entity_id": "demo_request",
2908
+ "views": [
2909
+ {"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title", "status", "owner"], "being_default": True},
2910
+ {"view_id": "board", "name": "看板", "type": "board", "field_ids": ["title", "status"], "group_by_field_id": "status"},
2911
+ ],
2912
+ }
2913
+ ],
2914
+ },
2915
+ "demo": {
2916
+ "solution_name": "Demo Workspace",
2917
+ "entities": [
2918
+ {
2919
+ "entity_id": "field_demo",
2920
+ "views": [
2921
+ {"view_id": "table", "name": "字段表格", "type": "table", "field_ids": ["title", "category", "assignee"], "being_default": True},
2922
+ {"view_id": "card", "name": "卡片", "type": "card", "field_ids": ["title", "category", "due_at"]},
2923
+ ],
2924
+ }
2925
+ ],
2926
+ },
2927
+ },
2928
+ "analytics_portal": {
2929
+ "minimal": {
2930
+ "solution_name": "Demo Workspace",
2931
+ "entities": [{"entity_id": "demo_request", "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}]}],
2932
+ },
2933
+ "full": {
2934
+ "solution_name": "Demo Workspace",
2935
+ "entities": [{"entity_id": "demo_request", "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}]}],
2936
+ "portal": {"name": "Demo 首页", "sections": [{"section_id": "total", "title": "总量", "source_type": "chart", "entity_id": "demo_request", "chart_id": "total"}]},
2937
+ },
2938
+ "demo": {
2939
+ "solution_name": "Demo Workspace",
2940
+ "entities": [{"entity_id": "field_demo", "charts": [{"chart_id": "by_category", "name": "按类型统计", "chart_type": "pie", "dimension_field_ids": ["category"], "config": {"aggregate": "count"}}]}],
2941
+ "portal": {"name": "字段演示首页", "sections": [{"section_id": "category_chart", "title": "类型分布", "source_type": "chart", "entity_id": "field_demo", "chart_id": "by_category"}]},
2942
+ },
2943
+ },
2944
+ "navigation": {
2945
+ "minimal": {"solution_name": "Demo Workspace", "navigation": {"items": [{"item_id": "package_root", "title": "Demo Workspace", "target_type": "package"}]}},
2946
+ "full": {"solution_name": "Demo Workspace", "navigation": {"items": [{"item_id": "package_root", "title": "Demo Workspace", "target_type": "package"}, {"item_id": "demo_request", "title": "测试申请", "target_type": "app", "entity_id": "demo_request"}]}},
2947
+ "demo": {"solution_name": "Demo Workspace", "navigation": {"items": [{"item_id": "package_root", "title": "字段演示", "target_type": "package"}, {"item_id": "field_demo", "title": "字段演示", "target_type": "app", "entity_id": "field_demo"}]}},
2948
+ },
2949
+ "app_flow": {
2950
+ "minimal": {
2951
+ "solution_name": "Demo Workspace",
2952
+ "entities": [
2953
+ {
2954
+ "entity_id": "demo_request",
2955
+ "display_name": "测试申请",
2956
+ "kind": "transaction",
2957
+ "title_field_id": "title",
2958
+ "status_field_id": "status",
2959
+ "fields": [
2960
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
2961
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "已完成"]},
2962
+ ],
2963
+ "form_layout": {"rows": [{"field_ids": ["title", "status"]}]},
2964
+ "workflow": {"enabled": True, "nodes": [{"node_id": "approve", "name": "审批", "node_type": "audit"}]},
2965
+ }
2966
+ ],
2967
+ },
2968
+ "full": {},
2969
+ "demo": {},
2970
+ },
2971
+ "all": {
2972
+ "minimal": {
2973
+ "solution_name": "Demo Workspace",
2974
+ "entities": [
2975
+ {
2976
+ "entity_id": "demo_request",
2977
+ "display_name": "测试申请",
2978
+ "kind": "transaction",
2979
+ "title_field_id": "title",
2980
+ "fields": [{"field_id": "title", "label": "标题", "type": "text", "required": True}],
2981
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
2982
+ }
2983
+ ],
2984
+ },
2985
+ "full": {
2986
+ "solution_name": "Demo Workspace",
2987
+ "package": {"name": "Demo Package"},
2988
+ "entities": [
2989
+ {
2990
+ "entity_id": "demo_request",
2991
+ "display_name": "测试申请",
2992
+ "kind": "transaction",
2993
+ "title_field_id": "title",
2994
+ "status_field_id": "status",
2995
+ "fields": [
2996
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
2997
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
2998
+ ],
2999
+ "form_layout": {"rows": [{"field_ids": ["title", "status"]}]},
3000
+ "workflow": {"enabled": True, "nodes": [{"node_id": "approve", "name": "审批", "node_type": "audit"}]},
3001
+ "views": [{"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title", "status"], "being_default": True}],
3002
+ "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}],
3003
+ }
3004
+ ],
3005
+ "portal": {"name": "Demo 首页", "sections": [{"section_id": "total", "title": "总量", "source_type": "chart", "entity_id": "demo_request", "chart_id": "total"}]},
3006
+ "navigation": {"items": [{"item_id": "package_root", "title": "Demo Workspace", "target_type": "package"}]},
3007
+ },
3008
+ "demo": {},
3009
+ },
3010
+ }
3011
+ if not examples["app_flow"]["full"]:
3012
+ examples["app_flow"]["full"] = deepcopy(examples["all"]["full"])
3013
+ if not examples["app_flow"]["demo"]:
3014
+ examples["app_flow"]["demo"] = deepcopy(examples["app"]["demo"])
3015
+ examples["app_flow"]["demo"]["entities"][0]["status_field_id"] = "status"
3016
+ examples["app_flow"]["demo"]["entities"][0]["fields"].append(
3017
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]}
3018
+ )
3019
+ examples["app_flow"]["demo"]["entities"][0]["workflow"] = {
3020
+ "enabled": True,
3021
+ "nodes": [
3022
+ {"node_id": "start", "name": "发起", "node_type": "start"},
3023
+ {"node_id": "approve", "name": "审批", "node_type": "audit"},
3024
+ ],
3025
+ }
3026
+ if not examples["all"]["demo"]:
3027
+ examples["all"]["demo"] = deepcopy(examples["all"]["full"])
3028
+ return examples[stage]
3029
+
3030
+
3031
+ def _solution_error_fields(
3032
+ *,
3033
+ category: str,
3034
+ detail: Any,
3035
+ suggested_next_call: dict[str, Any] | None,
3036
+ stage: str | None = None,
3037
+ ) -> dict[str, Any]:
3038
+ detail_text = str(detail)
3039
+ missing_required_fields: list[str] = []
3040
+ unknown_fields: list[str] = []
3041
+ invalid_field_types: list[str] = []
3042
+ invalid_enum_values: list[str] = []
3043
+ if isinstance(detail, list):
3044
+ for item in detail:
3045
+ if not isinstance(item, dict):
3046
+ continue
3047
+ loc = ".".join(str(part) for part in item.get("loc", []))
3048
+ err_type = str(item.get("type", ""))
3049
+ if err_type.endswith("missing") and loc:
3050
+ missing_required_fields.append(loc)
3051
+ if err_type == "extra_forbidden" and loc:
3052
+ unknown_fields.append(loc)
3053
+ if (err_type.endswith("_type") or err_type in {"enum", "literal_error", "model_type", "list_type", "dict_type"}) and loc:
3054
+ invalid_field_types.append(loc)
3055
+ if err_type in {"enum", "literal_error"} and loc:
3056
+ invalid_enum_values.append(loc)
3057
+ if "build_id is required" in detail_text:
3058
+ missing_required_fields.append("build_id")
3059
+ if "session_id is required" in detail_text:
3060
+ missing_required_fields.append("session_id")
3061
+ if "solution_spec must be a non-empty object" in detail_text:
3062
+ missing_required_fields.append("solution_spec")
3063
+ if "design_patch must be a non-empty object" in detail_text:
3064
+ missing_required_fields.append("design_patch")
3065
+ recoverable = category in {"config", "validation"}
3066
+ error_code = f"SOLUTION_{category.upper()}"
3067
+ normalized_stage = "app" if stage == "app_flow" else stage
3068
+ suggested_schema_call = None
3069
+ if recoverable and normalized_stage in SOLUTION_SCHEMA_STAGES:
3070
+ suggested_schema_call = {
3071
+ "tool_name": "solution_schema_example",
3072
+ "arguments": {"stage": normalized_stage, "intent": "minimal"},
3073
+ }
3074
+ return {
3075
+ "error_code": error_code,
3076
+ "recoverable": recoverable,
3077
+ "missing_required_fields": sorted(dict.fromkeys(missing_required_fields)),
3078
+ "unknown_fields": sorted(dict.fromkeys(unknown_fields)),
3079
+ "invalid_field_types": sorted(dict.fromkeys(invalid_field_types)),
3080
+ "invalid_enum_values": sorted(dict.fromkeys(invalid_enum_values)),
3081
+ "suggested_next_call": suggested_next_call,
3082
+ "suggested_schema_call": suggested_schema_call,
3083
+ }
3084
+
3085
+
3086
+ def _builder_package_resolution_failed(
3087
+ *,
3088
+ package_name: str,
3089
+ package_tag_id: int,
3090
+ error: QingflowApiError,
3091
+ retried: bool,
3092
+ ) -> dict[str, Any]:
3093
+ if package_tag_id > 0:
3094
+ detail = f"failed to resolve package_tag_id '{package_tag_id}': {error.message}"
3095
+ elif error.backend_code in {40002, 40027}:
3096
+ detail = (
3097
+ f"failed to resolve package '{package_name}' because package listing is permission-restricted; "
3098
+ "provide package_tag_id explicitly or use an account that can list packages"
3099
+ )
3100
+ else:
3101
+ detail = f"failed to resolve package '{package_name}': {error.message}"
3102
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
3103
+ error_fields["error_code"] = "PACKAGE_RESOLVE_FAILED"
3104
+ return {
3105
+ "status": "failed",
3106
+ "response": {
3107
+ "status": "failed",
3108
+ "mode": "plan",
3109
+ "stage": "app",
3110
+ "errors": [{"category": "config", "detail": detail}],
3111
+ "package_resolution": {
3112
+ "status": "failed",
3113
+ "requested_name": package_name or None,
3114
+ "requested_tag_id": package_tag_id or None,
3115
+ "resolution_policy": "exact_name_only" if package_name else "tag_id_only",
3116
+ "candidates": [],
3117
+ "source_shape": None,
3118
+ "retried": retried,
3119
+ "transport_error": {
3120
+ "http_status": error.http_status,
3121
+ "backend_code": error.backend_code,
3122
+ "category": error.category,
3123
+ "request_id": error.request_id,
3124
+ },
3125
+ },
3126
+ **error_fields,
3127
+ },
3128
+ }
3129
+
3130
+
3131
+ def _coerce_solution_api_error(error: Exception) -> QingflowApiError:
3132
+ if isinstance(error, QingflowApiError):
3133
+ return error
3134
+ if isinstance(error, RuntimeError):
3135
+ try:
3136
+ payload = json.loads(str(error))
3137
+ except json.JSONDecodeError:
3138
+ payload = None
3139
+ if isinstance(payload, dict) and payload.get("category") and payload.get("message"):
3140
+ details = payload.get("details")
3141
+ return QingflowApiError(
3142
+ category=str(payload.get("category")),
3143
+ message=str(payload.get("message")),
3144
+ backend_code=payload.get("backend_code"),
3145
+ request_id=payload.get("request_id"),
3146
+ http_status=payload.get("http_status"),
3147
+ details=details if isinstance(details, dict) else None,
3148
+ )
3149
+ return QingflowApiError(category="runtime", message=str(error))
3150
+
3151
+
3152
+ def _coerce_count(value: Any) -> int | None:
3153
+ if isinstance(value, bool) or value is None:
3154
+ return None
3155
+ if isinstance(value, int):
3156
+ return value
3157
+ if isinstance(value, float):
3158
+ return int(value)
3159
+ if isinstance(value, str):
3160
+ text = value.strip()
3161
+ if not text:
3162
+ return None
3163
+ try:
3164
+ return int(text)
3165
+ except ValueError:
3166
+ return None
3167
+ return None
3168
+
3169
+
3170
+ def _failure_signature_for_stage(*, stage_name: str, stage_payload: dict[str, Any], errors: list[dict[str, Any]]) -> str | None:
3171
+ error_steps = sorted({str(error.get("step_name")) for error in errors if isinstance(error, dict) and error.get("step_name")})
3172
+ if stage_name != "app":
3173
+ return fingerprint_payload(
3174
+ {
3175
+ "stage": stage_name,
3176
+ "error_steps": error_steps,
3177
+ }
3178
+ )
3179
+ entities = stage_payload.get("entities")
3180
+ if not isinstance(entities, list) or not entities:
3181
+ return fingerprint_payload(
3182
+ {
3183
+ "stage": stage_name,
3184
+ "error_steps": error_steps,
3185
+ }
3186
+ )
3187
+ normalized_entities = [_normalized_entity_failure_payload(entity) for entity in entities if isinstance(entity, dict)]
3188
+ if not normalized_entities:
3189
+ return fingerprint_payload(
3190
+ {
3191
+ "stage": stage_name,
3192
+ "error_steps": error_steps,
3193
+ }
3194
+ )
3195
+ return fingerprint_payload(
3196
+ {
3197
+ "stage": stage_name,
3198
+ "entities": normalized_entities,
3199
+ "error_steps": error_steps,
3200
+ }
3201
+ )
3202
+
3203
+
3204
+ def _normalized_entity_failure_payload(entity: dict[str, Any]) -> dict[str, Any]:
3205
+ fields = entity.get("fields")
3206
+ layout = entity.get("form_layout")
3207
+ normalized_fields = []
3208
+ if isinstance(fields, list):
3209
+ for field in fields:
3210
+ if not isinstance(field, dict):
3211
+ continue
3212
+ normalized_fields.append(
3213
+ {
3214
+ "field_id": field.get("field_id"),
3215
+ "type": field.get("type"),
3216
+ "required": bool(field.get("required")),
3217
+ }
3218
+ )
3219
+ return {
3220
+ "entity_id": entity.get("entity_id"),
3221
+ "fields": normalized_fields,
3222
+ "layout": _normalized_layout_signature(layout if isinstance(layout, dict) else {}),
3223
+ }
3224
+
3225
+
3226
+ def _normalized_layout_signature(layout: dict[str, Any]) -> dict[str, Any]:
3227
+ rows = layout.get("rows")
3228
+ sections = layout.get("sections")
3229
+ normalized_rows: list[list[str]] = []
3230
+ if isinstance(rows, list):
3231
+ for row in rows:
3232
+ if isinstance(row, dict):
3233
+ normalized_rows.append(list(row.get("field_ids", [])))
3234
+ normalized_sections = []
3235
+ if isinstance(sections, list):
3236
+ for section in sections:
3237
+ if not isinstance(section, dict):
3238
+ continue
3239
+ normalized_sections.append(
3240
+ {
3241
+ "section_id": section.get("section_id"),
3242
+ "rows": [
3243
+ list(row.get("field_ids", []))
3244
+ for row in section.get("rows", [])
3245
+ if isinstance(row, dict)
3246
+ ],
3247
+ }
3248
+ )
3249
+ return {"rows": normalized_rows, "sections": normalized_sections}
3250
+
3251
+
3252
+ def _looks_like_full_solution_spec(payload: dict[str, Any]) -> bool:
3253
+ if not isinstance(payload, dict):
3254
+ return False
3255
+ if not isinstance(payload.get("entities"), list):
3256
+ return False
3257
+ return any(
3258
+ key in payload
3259
+ for key in (
3260
+ "portal",
3261
+ "navigation",
3262
+ "requirements",
3263
+ "success_metrics",
3264
+ "roles",
3265
+ "package",
3266
+ "business_context",
3267
+ )
3268
+ )
3269
+
3270
+
3271
+ def _project_stage_payload(stage_name: str, solution_spec: dict[str, Any]) -> dict[str, Any]:
3272
+ if stage_name == "app":
3273
+ entities = deepcopy(solution_spec.get("entities", []))
3274
+ for entity in entities:
3275
+ if not isinstance(entity, dict):
3276
+ continue
3277
+ entity.pop("workflow", None)
3278
+ entity.pop("lifecycle_stages", None)
3279
+ entity.pop("views", None)
3280
+ entity.pop("charts", None)
3281
+ projected = {
3282
+ "solution_name": solution_spec.get("solution_name"),
3283
+ "summary": solution_spec.get("summary"),
3284
+ "business_context": deepcopy(solution_spec.get("business_context", {})),
3285
+ "package": deepcopy(solution_spec.get("package", {})),
3286
+ "entities": entities,
3287
+ "roles": deepcopy(solution_spec.get("roles", [])),
3288
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
3289
+ "preferences": deepcopy(solution_spec.get("preferences", {})),
3290
+ "assumptions": deepcopy(solution_spec.get("assumptions", [])),
3291
+ "constraints": deepcopy(solution_spec.get("constraints", [])),
3292
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3293
+ }
3294
+ return {key: value for key, value in projected.items() if value is not None}
3295
+
3296
+ if stage_name == "flow":
3297
+ projected_entities: list[dict[str, Any]] = []
3298
+ for entity in solution_spec.get("entities", []):
3299
+ if not isinstance(entity, dict):
3300
+ continue
3301
+ flow_entity = {"entity_id": entity.get("entity_id")}
3302
+ for key in ("workflow", "lifecycle_stages", "status_field_id", "owner_field_id", "start_field_id", "end_field_id", "metadata"):
3303
+ if key in entity:
3304
+ flow_entity[key] = deepcopy(entity[key])
3305
+ projected_entities.append(flow_entity)
3306
+ return {
3307
+ "solution_name": solution_spec.get("solution_name"),
3308
+ "entities": projected_entities,
3309
+ "roles": deepcopy(solution_spec.get("roles", [])),
3310
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
3311
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3312
+ }
3313
+
3314
+ if stage_name == "app_flow":
3315
+ entities = deepcopy(solution_spec.get("entities", []))
3316
+ for entity in entities:
3317
+ if not isinstance(entity, dict):
3318
+ continue
3319
+ entity.pop("views", None)
3320
+ entity.pop("charts", None)
3321
+ projected = {
3322
+ "solution_name": solution_spec.get("solution_name"),
3323
+ "summary": solution_spec.get("summary"),
3324
+ "business_context": deepcopy(solution_spec.get("business_context", {})),
3325
+ "package": deepcopy(solution_spec.get("package", {})),
3326
+ "entities": entities,
3327
+ "roles": deepcopy(solution_spec.get("roles", [])),
3328
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
3329
+ "preferences": deepcopy(solution_spec.get("preferences", {})),
3330
+ "assumptions": deepcopy(solution_spec.get("assumptions", [])),
3331
+ "constraints": deepcopy(solution_spec.get("constraints", [])),
3332
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3333
+ }
3334
+ return {key: value for key, value in projected.items() if value is not None}
3335
+
3336
+ if stage_name == "views":
3337
+ return {
3338
+ "solution_name": solution_spec.get("solution_name"),
3339
+ "entities": [
3340
+ {
3341
+ "entity_id": entity.get("entity_id"),
3342
+ "views": deepcopy(entity.get("views", [])),
3343
+ }
3344
+ for entity in solution_spec.get("entities", [])
3345
+ if isinstance(entity, dict) and entity.get("views")
3346
+ ],
3347
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3348
+ }
3349
+
3350
+ if stage_name == "analytics_portal":
3351
+ return {
3352
+ "solution_name": solution_spec.get("solution_name"),
3353
+ "entities": [
3354
+ {
3355
+ "entity_id": entity.get("entity_id"),
3356
+ "charts": deepcopy(entity.get("charts", [])),
3357
+ }
3358
+ for entity in solution_spec.get("entities", [])
3359
+ if isinstance(entity, dict) and entity.get("charts")
3360
+ ],
3361
+ "portal": deepcopy(solution_spec.get("portal", {})),
3362
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
3363
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3364
+ }
3365
+
3366
+ if stage_name == "navigation":
3367
+ return {
3368
+ "solution_name": solution_spec.get("solution_name"),
3369
+ "navigation": deepcopy(solution_spec.get("navigation", {})),
3370
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
3371
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
3372
+ }
3373
+ raise ValueError(f"unsupported stage '{stage_name}'")
3374
+
3375
+
3376
+ def _split_solution_spec_into_stage_payloads(solution_spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
3377
+ return {stage_name: _project_stage_payload(stage_name, solution_spec) for stage_name in STAGE_ORDER}
3378
+
3379
+
3380
+ def _stage_payload_is_empty(stage_name: str, stage_payload: dict[str, Any]) -> bool:
3381
+ if stage_name == "app_flow":
3382
+ return not bool(stage_payload.get("entities"))
3383
+ if stage_name == "views":
3384
+ return not any(
3385
+ isinstance(entity, dict) and entity.get("views")
3386
+ for entity in stage_payload.get("entities", [])
3387
+ )
3388
+ if stage_name == "analytics_portal":
3389
+ has_charts = any(
3390
+ isinstance(entity, dict) and entity.get("charts")
3391
+ for entity in stage_payload.get("entities", [])
3392
+ )
3393
+ portal_payload = stage_payload.get("portal", {}) if isinstance(stage_payload.get("portal"), dict) else {}
3394
+ has_portal = bool(portal_payload.get("sections")) and portal_payload.get("enabled", True) is not False
3395
+ return not has_charts and not has_portal
3396
+ if stage_name == "navigation":
3397
+ navigation_payload = stage_payload.get("navigation", {}) if isinstance(stage_payload.get("navigation"), dict) else {}
3398
+ return not (navigation_payload.get("enabled", True) and navigation_payload.get("items"))
3399
+ raise ValueError(f"unsupported stage '{stage_name}'")
3400
+
3401
+
3402
+ def _stage_skip_reason(stage_name: str) -> str:
3403
+ reasons = {
3404
+ "app_flow": "No app flow entities were provided for this build stage.",
3405
+ "views": "No view definitions were provided, so the views stage was skipped.",
3406
+ "analytics_portal": "No charts or enabled portal sections were provided, so the analytics stage was skipped.",
3407
+ "navigation": "No enabled navigation items were provided, so the navigation stage was skipped.",
3408
+ }
3409
+ return reasons.get(stage_name, "Stage skipped because no applicable payload was provided.")
3410
+
3411
+
3412
+ def _append_verification_error(verification: dict[str, Any], scope: str, exc: Exception) -> None:
3413
+ errors = verification.setdefault("errors", [])
3414
+ if isinstance(errors, list):
3415
+ errors.append(
3416
+ {
3417
+ "scope": scope,
3418
+ "message": str(exc),
3419
+ }
3420
+ )
3421
+
3422
+
3423
+ PREFERRED_RECORD_TITLE_LABELS = (
3424
+ "名称",
3425
+ "客户名称",
3426
+ "医疗机构名称",
3427
+ "联系人姓名",
3428
+ "商机名称",
3429
+ "销售机会名称",
3430
+ "合同名称",
3431
+ "采购合同名称",
3432
+ "拜访主题",
3433
+ "主题",
3434
+ "项目名称",
3435
+ "任务名称",
3436
+ )
3437
+
3438
+
3439
+ def _readback_record_page(
3440
+ record_tools: RecordTools,
3441
+ profile: str,
3442
+ app_key: str,
3443
+ *,
3444
+ entity_spec: dict[str, Any] | None = None,
3445
+ field_meta: dict[str, Any] | None = None,
3446
+ ) -> dict[str, Any]:
3447
+ select_columns = _readback_select_columns(entity_spec=entity_spec, field_meta=field_meta)
3448
+ fallback: dict[str, Any] = {}
3449
+ for list_type in (8, 9):
3450
+ response = record_tools.record_query(
3451
+ profile=profile,
3452
+ query_mode="list",
3453
+ app_key=app_key,
3454
+ apply_id=None,
3455
+ page_num=1,
3456
+ page_size=20,
3457
+ requested_pages=1,
3458
+ scan_max_pages=1,
3459
+ query_key=None,
3460
+ filters=[],
3461
+ sorts=[],
3462
+ max_rows=20,
3463
+ max_columns=max(1, len(select_columns)),
3464
+ select_columns=select_columns,
3465
+ amount_column=None,
3466
+ time_range={},
3467
+ stat_policy={},
3468
+ strict_full=False,
3469
+ output_profile="compact",
3470
+ list_type=list_type,
3471
+ view_key=None,
3472
+ view_name=None,
3473
+ )
3474
+ data = response.get("data") if isinstance(response, dict) else None
3475
+ list_payload = data.get("list") if isinstance(data, dict) and isinstance(data.get("list"), dict) else {}
3476
+ pagination = list_payload.get("pagination") if isinstance(list_payload.get("pagination"), dict) else {}
3477
+ rows = list_payload.get("rows") if isinstance(list_payload.get("rows"), list) else []
3478
+ merged_page = {
3479
+ "list": rows,
3480
+ "backend_reported_total": _coerce_count(pagination.get("backend_reported_total")),
3481
+ "returned_rows": _coerce_count(pagination.get("returned_items")) or len(rows),
3482
+ "effective_count": _coerce_count(pagination.get("result_amount")),
3483
+ "list_type": list_type,
3484
+ "list_type_label": get_record_list_type_label(list_type),
3485
+ "page_num": _coerce_count(pagination.get("page_num")) or 1,
3486
+ "page_size": _coerce_count(pagination.get("page_size")) or 20,
3487
+ }
3488
+ if not fallback and isinstance(merged_page, dict):
3489
+ fallback = merged_page
3490
+ if rows:
3491
+ return merged_page
3492
+ return fallback
3493
+
3494
+
3495
+ def _extract_record_titles(
3496
+ rows: list[Any],
3497
+ *,
3498
+ entity_spec: dict[str, Any] | None = None,
3499
+ field_meta: dict[str, Any] | None = None,
3500
+ ) -> list[str]:
3501
+ preferred_titles = set(PREFERRED_RECORD_TITLE_LABELS)
3502
+ title_labels, title_que_ids = _title_field_hints(entity_spec=entity_spec, field_meta=field_meta)
3503
+ titles: list[str] = []
3504
+ for row in rows:
3505
+ if not isinstance(row, dict):
3506
+ continue
3507
+ answers = row.get("answers")
3508
+ selected: str | None = None
3509
+ fallback: str | None = None
3510
+ if isinstance(answers, list):
3511
+ for answer in answers:
3512
+ if not isinstance(answer, dict):
3513
+ continue
3514
+ normalized_values = _extract_answer_values(answer)
3515
+ if not normalized_values:
3516
+ continue
3517
+ candidate = " / ".join(normalized_values)
3518
+ answer_title = answer.get("queTitle") or answer.get("title")
3519
+ answer_que_id = _coerce_count(answer.get("queId"))
3520
+ if answer_que_id in title_que_ids or answer_title in title_labels:
3521
+ selected = candidate
3522
+ break
3523
+ if fallback is None and not _looks_like_secondary_answer(answer_title, candidate):
3524
+ fallback = candidate
3525
+ if answer_title in preferred_titles:
3526
+ selected = candidate
3527
+ break
3528
+ title = selected or fallback
3529
+ if title is None:
3530
+ for label in [*sorted(title_labels), *[item for item in PREFERRED_RECORD_TITLE_LABELS if item not in title_labels]]:
3531
+ candidate = _normalize_row_title(row.get(label))
3532
+ if candidate and not _looks_like_secondary_answer(label, candidate):
3533
+ title = candidate
3534
+ break
3535
+ if title is None:
3536
+ for key, value in row.items():
3537
+ if key in {"apply_id", "id"}:
3538
+ continue
3539
+ candidate = _normalize_row_title(value)
3540
+ if candidate and not _looks_like_secondary_answer(key, candidate):
3541
+ title = candidate
3542
+ break
3543
+ if title is None:
3544
+ for key in ("title", "name", "recordName", "applyName"):
3545
+ value = row.get(key)
3546
+ candidate = _normalize_row_title(value)
3547
+ if candidate:
3548
+ title = candidate
3549
+ break
3550
+ if title:
3551
+ titles.append(title)
3552
+ return titles[:5]
3553
+
3554
+
3555
+ def _readback_select_columns(
3556
+ *,
3557
+ entity_spec: dict[str, Any] | None,
3558
+ field_meta: dict[str, Any] | None,
3559
+ ) -> list[str]:
3560
+ title_labels, _ = _title_field_hints(entity_spec=entity_spec, field_meta=field_meta)
3561
+ by_label = field_meta.get("by_label", {}) if isinstance(field_meta, dict) and isinstance(field_meta.get("by_label"), dict) else {}
3562
+ selected: list[str] = []
3563
+
3564
+ def append(label: Any) -> None:
3565
+ if not isinstance(label, str) or not label or label in selected:
3566
+ return
3567
+ if by_label and label not in by_label:
3568
+ return
3569
+ selected.append(label)
3570
+
3571
+ for label in sorted(title_labels):
3572
+ append(label)
3573
+ for label in PREFERRED_RECORD_TITLE_LABELS:
3574
+ append(label)
3575
+ if isinstance(by_label, dict):
3576
+ for label in by_label:
3577
+ append(label)
3578
+ if len(selected) >= 5:
3579
+ break
3580
+ if not selected and isinstance(entity_spec, dict):
3581
+ for field in entity_spec.get("fields", []):
3582
+ if not isinstance(field, dict):
3583
+ continue
3584
+ append(field.get("label"))
3585
+ if len(selected) >= 5:
3586
+ break
3587
+ return selected[:5] or ["名称"]
3588
+
3589
+
3590
+ def _title_field_hints(
3591
+ *,
3592
+ entity_spec: dict[str, Any] | None,
3593
+ field_meta: dict[str, Any] | None,
3594
+ ) -> tuple[set[str], set[int]]:
3595
+ title_labels: set[str] = set()
3596
+ title_que_ids: set[int] = set()
3597
+ if not isinstance(entity_spec, dict):
3598
+ return title_labels, title_que_ids
3599
+ title_field_candidates = {str(entity_spec.get("title_field_id") or "title")}
3600
+ for field in entity_spec.get("fields", []):
3601
+ if not isinstance(field, dict):
3602
+ continue
3603
+ field_id = field.get("field_id")
3604
+ field_label = field.get("label")
3605
+ field_config = field.get("config", {}) if isinstance(field.get("config"), dict) else {}
3606
+ if field_id in title_field_candidates or field_config.get("role") == "title":
3607
+ if isinstance(field_label, str) and field_label:
3608
+ title_labels.add(field_label)
3609
+ if isinstance(field_id, str):
3610
+ title_field_candidates.add(field_id)
3611
+ if isinstance(field_meta, dict):
3612
+ by_field_id = field_meta.get("by_field_id", {})
3613
+ if isinstance(by_field_id, dict):
3614
+ for field_id in title_field_candidates:
3615
+ que_id = _coerce_count(by_field_id.get(field_id))
3616
+ if que_id is not None:
3617
+ title_que_ids.add(que_id)
3618
+ return title_labels, title_que_ids
3619
+
3620
+
3621
+ def _extract_answer_values(answer: dict[str, Any]) -> list[str]:
3622
+ values = answer.get("values")
3623
+ normalized_values: list[str] = []
3624
+ if isinstance(values, list):
3625
+ for value in values:
3626
+ if isinstance(value, str) and value:
3627
+ normalized_values.append(value)
3628
+ continue
3629
+ if isinstance(value, dict):
3630
+ for key in ("value", "name", "text", "label"):
3631
+ item = value.get(key)
3632
+ if isinstance(item, str) and item:
3633
+ normalized_values.append(item)
3634
+ break
3635
+ elif isinstance(values, str) and values:
3636
+ normalized_values.append(values)
3637
+ return normalized_values
3638
+
3639
+
3640
+ def _normalize_row_title(value: Any) -> str | None:
3641
+ if isinstance(value, str):
3642
+ text = value.strip()
3643
+ return text or None
3644
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
3645
+ return str(value)
3646
+ if isinstance(value, list):
3647
+ parts = [_normalize_row_title(item) for item in value]
3648
+ normalized = [part for part in parts if isinstance(part, str) and part]
3649
+ return " / ".join(normalized) if normalized else None
3650
+ if isinstance(value, dict):
3651
+ for key in ("value", "name", "text", "label"):
3652
+ candidate = _normalize_row_title(value.get(key))
3653
+ if candidate:
3654
+ return candidate
3655
+ return None
3656
+
3657
+
3658
+ def _looks_like_secondary_answer(answer_title: Any, candidate: str) -> bool:
3659
+ if not isinstance(candidate, str) or not candidate:
3660
+ return True
3661
+ title = str(answer_title or "")
3662
+ secondary_markers = ("状态", "日期", "时间", "负责人", "处理人", "优先级", "金额", "费用", "电话", "类型")
3663
+ if any(marker in title for marker in secondary_markers):
3664
+ return True
3665
+ if len(candidate) >= 10 and candidate[4] == "-" and candidate[7] == "-":
3666
+ return True
3667
+ return False
3668
+
3669
+
3670
+ def _count_portal_components(payload: dict[str, Any]) -> int | None:
3671
+ if not isinstance(payload, dict):
3672
+ return None
3673
+ for key in ("components", "sections", "dashItemList"):
3674
+ items = payload.get(key)
3675
+ if isinstance(items, list):
3676
+ return len(items)
3677
+ return None
3678
+
3679
+
3680
+ def _extract_navigation_items(items: list[Any]) -> list[dict[str, Any]]:
3681
+ extracted: list[dict[str, Any]] = []
3682
+ for item in items:
3683
+ if not isinstance(item, dict):
3684
+ continue
3685
+ result = item.get("result") if isinstance(item.get("result"), dict) else {}
3686
+ result_payload = result.get("result") if isinstance(result.get("result"), dict) else {}
3687
+ summarized = {
3688
+ "item_id": item.get("item_id"),
3689
+ "navigation_item_id": result_payload.get("navigationItemId") or result_payload.get("id"),
3690
+ "title": result_payload.get("navigationItemName") or result_payload.get("title"),
3691
+ }
3692
+ extracted.append({key: value for key, value in summarized.items() if value is not None})
3693
+ return extracted
3694
+
3695
+
3696
+ def _resolve_app_stage_views_strategy(stage_result: dict[str, Any]) -> str:
3697
+ verification = stage_result.get("verification")
3698
+ if isinstance(verification, dict):
3699
+ strategy = verification.get("views_strategy")
3700
+ if isinstance(strategy, str) and strategy:
3701
+ return strategy
3702
+ return "not_included"
3703
+
3704
+
3705
+ def _resolve_target_package_tag_id(target: dict[str, Any]) -> int | None:
3706
+ if not target:
3707
+ return None
3708
+ package_tag_id = target.get("package_tag_id")
3709
+ if package_tag_id is None and isinstance(target.get("package"), dict):
3710
+ package_tag_id = target["package"].get("tag_id")
3711
+ if package_tag_id is None:
3712
+ return None
3713
+ if not isinstance(package_tag_id, int) or package_tag_id <= 0:
3714
+ raise ValueError("target.package_tag_id must be a positive integer")
3715
+ return package_tag_id
3716
+
3717
+
3718
+ def _bind_existing_package_target(compiled: CompiledSolution, package_tag_id: int) -> CompiledSolution:
3719
+ compiled.package_payload = None
3720
+ compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False, attach_package=True)
3721
+ for entity in compiled.entities:
3722
+ entity.app_create_payload["tagIds"] = [package_tag_id]
3723
+ return compiled
3724
+
3725
+
3726
+ def _bind_existing_app_target(compiled: CompiledSolution) -> CompiledSolution:
3727
+ compiled.package_payload = None
3728
+ compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False, attach_package=False)
3729
+ return compiled
3730
+
3731
+
3732
+ def _merge_stage_manifest(*, stage_name: str, manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3733
+ merged = deepcopy(manifest or default_manifest())
3734
+ if stage_name == "app_flow":
3735
+ return _merge_app_flow_manifest(merged, stage_payload)
3736
+ if stage_name == "views":
3737
+ return _merge_views_manifest(merged, stage_payload)
3738
+ if stage_name == "analytics_portal":
3739
+ return _merge_analytics_portal_manifest(merged, stage_payload)
3740
+ if stage_name == "navigation":
3741
+ return _merge_navigation_manifest(merged, stage_payload)
3742
+ raise ValueError(f"unsupported stage '{stage_name}'")
3743
+
3744
+
3745
+ def _merge_app_flow_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3746
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
3747
+ merged_entities: list[dict[str, Any]] = []
3748
+ staged_entity_ids: set[str] = set()
3749
+ for entity in stage_payload.get("entities", []):
3750
+ existing = entity_map.get(entity["entity_id"], {})
3751
+ next_entity = deepcopy(existing)
3752
+ next_entity.update(deepcopy(entity))
3753
+ next_entity["views"] = deepcopy(existing.get("views", []))
3754
+ next_entity["charts"] = deepcopy(existing.get("charts", []))
3755
+ merged_entities.append(next_entity)
3756
+ staged_entity_ids.add(entity["entity_id"])
3757
+ for entity_id, entity in entity_map.items():
3758
+ if entity_id not in staged_entity_ids:
3759
+ merged_entities.append(deepcopy(entity))
3760
+ manifest["solution_name"] = stage_payload.get("solution_name", manifest.get("solution_name"))
3761
+ manifest["summary"] = stage_payload.get("summary", manifest.get("summary"))
3762
+ manifest["business_context"] = deepcopy(stage_payload.get("business_context", manifest.get("business_context", {})))
3763
+ manifest["package"] = deepcopy(_merge_dict(manifest.get("package", {}), stage_payload.get("package", {})))
3764
+ manifest["entities"] = merged_entities
3765
+ if "roles" in stage_payload:
3766
+ manifest["roles"] = deepcopy(stage_payload["roles"])
3767
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload.get("publish_policy", {})))
3768
+ manifest["preferences"] = deepcopy(_merge_dict(manifest.get("preferences", {}), stage_payload.get("preferences", {})))
3769
+ if "assumptions" in stage_payload:
3770
+ manifest["assumptions"] = deepcopy(stage_payload["assumptions"])
3771
+ if "constraints" in stage_payload:
3772
+ manifest["constraints"] = deepcopy(stage_payload["constraints"])
3773
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
3774
+ return manifest
3775
+
3776
+
3777
+ def _merge_views_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3778
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
3779
+ for entity_patch in stage_payload.get("entities", []):
3780
+ entity_id = entity_patch["entity_id"]
3781
+ if entity_id not in entity_map:
3782
+ raise ValueError(f"views stage references unknown entity '{entity_id}'")
3783
+ entity_map[entity_id]["views"] = deepcopy(entity_patch.get("views", []))
3784
+ manifest["entities"] = list(entity_map.values())
3785
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
3786
+ return manifest
3787
+
3788
+
3789
+ def _merge_analytics_portal_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3790
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
3791
+ for entity_patch in stage_payload.get("entities", []):
3792
+ entity_id = entity_patch["entity_id"]
3793
+ if entity_id not in entity_map:
3794
+ raise ValueError(f"analytics stage references unknown entity '{entity_id}'")
3795
+ entity_map[entity_id]["charts"] = deepcopy(entity_patch.get("charts", []))
3796
+ manifest["entities"] = list(entity_map.values())
3797
+ if "portal" in stage_payload:
3798
+ portal_payload = deepcopy(stage_payload["portal"])
3799
+ if portal_payload.get("sections"):
3800
+ manifest["portal"] = deepcopy(_merge_dict(manifest.get("portal", {}), portal_payload))
3801
+ manifest["portal"]["enabled"] = portal_payload.get("enabled", True)
3802
+ manifest.setdefault("preferences", {})
3803
+ manifest["preferences"]["create_portal"] = bool(portal_payload.get("enabled", True))
3804
+ elif portal_payload.get("enabled") is False:
3805
+ manifest["portal"] = deepcopy(_merge_dict(manifest.get("portal", {}), portal_payload))
3806
+ manifest.setdefault("preferences", {})
3807
+ manifest["preferences"]["create_portal"] = False
3808
+ if "publish_policy" in stage_payload:
3809
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload["publish_policy"]))
3810
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
3811
+ return manifest
3812
+
3813
+
3814
+ def _merge_navigation_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3815
+ navigation_payload = deepcopy(stage_payload.get("navigation", {}))
3816
+ manifest["navigation"] = deepcopy(_merge_dict(manifest.get("navigation", {}), navigation_payload))
3817
+ if navigation_payload.get("items"):
3818
+ manifest["navigation"]["enabled"] = navigation_payload.get("enabled", True)
3819
+ manifest.setdefault("preferences", {})
3820
+ manifest["preferences"]["create_navigation"] = bool(navigation_payload.get("enabled", True))
3821
+ if "publish_policy" in stage_payload:
3822
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload["publish_policy"]))
3823
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
3824
+ return manifest
3825
+
3826
+
3827
+ def _filter_compiled_solution(
3828
+ compiled: CompiledSolution,
3829
+ *,
3830
+ stage_name: str,
3831
+ stage_payload: dict[str, Any],
3832
+ stage_variant: str | None = None,
3833
+ ) -> CompiledSolution:
3834
+ included_step_names = _stage_step_names(
3835
+ compiled.execution_plan,
3836
+ stage_name=stage_name,
3837
+ stage_payload=stage_payload,
3838
+ stage_variant=stage_variant,
3839
+ )
3840
+ allowed = set(included_step_names)
3841
+ filtered_steps = [
3842
+ ExecutionStep(
3843
+ step_name=step.step_name,
3844
+ resource_type=step.resource_type,
3845
+ resource_ref=step.resource_ref,
3846
+ description=step.description,
3847
+ depends_on=[dependency for dependency in step.depends_on if dependency in allowed],
3848
+ )
3849
+ for step in compiled.execution_plan.steps
3850
+ if step.step_name in allowed
3851
+ ]
3852
+ filtered = deepcopy(compiled)
3853
+ filtered.execution_plan = ExecutionPlan(steps=filtered_steps)
3854
+ return filtered
3855
+
3856
+
3857
+ def _stage_step_names(
3858
+ plan: ExecutionPlan,
3859
+ *,
3860
+ stage_name: str,
3861
+ stage_payload: dict[str, Any],
3862
+ stage_variant: str | None = None,
3863
+ ) -> list[str]:
3864
+ variant = stage_variant or stage_name
3865
+ if variant == "navigation":
3866
+ return [step.step_name for step in plan.steps if step.step_name in {"navigation.create", "publish.navigation"}]
3867
+
3868
+ entity_ids = {
3869
+ entity["entity_id"]
3870
+ for entity in stage_payload.get("entities", [])
3871
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
3872
+ }
3873
+ included: list[str] = []
3874
+ for step in plan.steps:
3875
+ if variant == "app":
3876
+ if (
3877
+ step.step_name == "package.create"
3878
+ or step.step_name.startswith("role.create.")
3879
+ or any(
3880
+ step.step_name.startswith(prefix)
3881
+ for prefix in (
3882
+ "app.create.",
3883
+ "package.attach.",
3884
+ "form.base.",
3885
+ "form.relations.",
3886
+ "publish.form.",
3887
+ "publish.app.",
3888
+ "seed_data.",
3889
+ )
3890
+ )
3891
+ ):
3892
+ if "." in step.step_name and step.resource_ref not in entity_ids and not step.step_name.startswith("role.create.") and step.step_name != "package.create":
3893
+ continue
3894
+ included.append(step.step_name)
3895
+ continue
3896
+ if variant == "flow":
3897
+ if (
3898
+ step.step_name.startswith("role.create.")
3899
+ or step.step_name.startswith("workflow.")
3900
+ or step.step_name.startswith("publish.workflow.")
3901
+ or step.step_name.startswith("publish.app.")
3902
+ ):
3903
+ if "." in step.step_name and step.resource_ref not in entity_ids and not step.step_name.startswith("role.create."):
3904
+ continue
3905
+ included.append(step.step_name)
3906
+ continue
3907
+ if variant == "app_flow":
3908
+ if (
3909
+ step.step_name == "package.create"
3910
+ or step.step_name.startswith("role.create.")
3911
+ or any(
3912
+ step.step_name.startswith(prefix)
3913
+ for prefix in (
3914
+ "app.create.",
3915
+ "package.attach.",
3916
+ "form.base.",
3917
+ "form.relations.",
3918
+ "workflow.",
3919
+ "publish.form.",
3920
+ "publish.workflow.",
3921
+ "publish.app.",
3922
+ "seed_data.",
3923
+ )
3924
+ )
3925
+ ):
3926
+ if "." in step.step_name and step.resource_ref not in entity_ids and not step.step_name.startswith("role.create.") and step.step_name != "package.create":
3927
+ continue
3928
+ included.append(step.step_name)
3929
+ continue
3930
+ if variant == "views":
3931
+ if step.resource_ref in entity_ids and (step.step_name.startswith("views.") or step.step_name.startswith("publish.app.")):
3932
+ included.append(step.step_name)
3933
+ continue
3934
+ if variant == "analytics_portal":
3935
+ if step.resource_ref in entity_ids and (step.step_name.startswith("charts.") or step.step_name.startswith("publish.app.")):
3936
+ included.append(step.step_name)
3937
+ continue
3938
+ if step.step_name in {"portal.create", "publish.portal"}:
3939
+ included.append(step.step_name)
3940
+ continue
3941
+ return included
3942
+
3943
+
3944
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
3945
+ merged = deepcopy(base)
3946
+ for key, value in patch.items():
3947
+ merged[key] = deepcopy(value)
3948
+ return merged
3949
+
3950
+
3951
+ def _stage_run_id(build_id: str, stage_name: str) -> str:
3952
+ return f"{build_id}--{stage_name}"
3953
+
3954
+
3955
+ def _build_status_for_stage(stage_name: str, result_status: str) -> str:
3956
+ if result_status != "success":
3957
+ return result_status
3958
+ return "success" if stage_name == "navigation" else "ready"
3959
+
3960
+
3961
+ def _build_summary(assembly: BuildAssemblyStore) -> dict[str, Any]:
3962
+ summary = assembly.summary()
3963
+ stage_specs = summary.get("stage_specs", {}) if isinstance(summary.get("stage_specs"), dict) else {}
3964
+ stage_history = summary.get("stage_history", []) if isinstance(summary.get("stage_history"), list) else []
3965
+ latest_entries: dict[str, dict[str, Any]] = {}
3966
+ for entry in stage_history:
3967
+ stage = entry.get("stage")
3968
+ if isinstance(stage, str):
3969
+ latest_entries[stage] = entry
3970
+ stage_statuses: dict[str, str] = {}
3971
+ for stage in STAGE_ORDER:
3972
+ latest = latest_entries.get(stage)
3973
+ if latest and latest.get("status"):
3974
+ stage_statuses[stage] = str(latest["status"])
3975
+ continue
3976
+ stage_statuses[stage] = "draft" if stage_specs.get(stage) else "pending"
3977
+ ready_stages = [stage for stage in STAGE_ORDER if not _missing_stage_prerequisites(stage_name=stage, assembly=assembly, stage_payload=stage_specs.get(stage, {}) or {})]
3978
+ completed_stages = [stage for stage, status in stage_statuses.items() if status in {"success", "ready", "skipped"}]
3979
+ next_stage = next((stage for stage in STAGE_ORDER if stage_statuses.get(stage) not in {"success", "ready", "skipped"}), None)
3980
+ prerequisites = {
3981
+ stage: _missing_stage_prerequisites(stage_name=stage, assembly=assembly, stage_payload=stage_specs.get(stage, {}) or {})
3982
+ for stage in STAGE_ORDER
3983
+ }
3984
+ return {
3985
+ "build_id": summary.get("build_id"),
3986
+ "status": summary.get("status"),
3987
+ "build_path": summary.get("build_path"),
3988
+ "stage_statuses": stage_statuses,
3989
+ "completed_stages": completed_stages,
3990
+ "ready_stages": ready_stages,
3991
+ "next_recommended_stage": next_stage,
3992
+ "missing_prerequisites": prerequisites,
3993
+ "stage_history": stage_history,
3994
+ }
3995
+
3996
+
3997
+ def _missing_stage_prerequisites(*, stage_name: str, assembly: BuildAssemblyStore, stage_payload: dict[str, Any]) -> list[str]:
3998
+ manifest = assembly.get_manifest()
3999
+ stage_specs = (assembly.data.get("stage_specs", {}) or {}) if isinstance(assembly.data.get("stage_specs"), dict) else {}
4000
+ entity_map = {
4001
+ entity.get("entity_id"): entity
4002
+ for entity in manifest.get("entities", [])
4003
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
4004
+ }
4005
+ missing: list[str] = []
4006
+ if stage_name in {"app_flow", "app"}:
4007
+ return missing
4008
+ if stage_name == "flow":
4009
+ if not entity_map and not stage_specs.get("app_flow"):
4010
+ return ["Run solution_build_app first to define package, apps, and fields before configuring workflow."]
4011
+ for entity in stage_payload.get("entities", []):
4012
+ entity_id = entity.get("entity_id")
4013
+ if entity_id not in entity_map:
4014
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app first.")
4015
+ return list(dict.fromkeys(missing))
4016
+ if not entity_map and not stage_specs.get("app_flow"):
4017
+ return ["Run solution_build_app_flow first to define package, apps, and form/workflow entities."]
4018
+ if stage_name == "views":
4019
+ for entity in stage_payload.get("entities", []):
4020
+ entity_id = entity.get("entity_id")
4021
+ if entity_id not in entity_map:
4022
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app_flow first.")
4023
+ return missing
4024
+ if stage_name == "analytics_portal":
4025
+ for entity in stage_payload.get("entities", []):
4026
+ entity_id = entity.get("entity_id")
4027
+ if entity_id not in entity_map:
4028
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app_flow first.")
4029
+ portal_payload = stage_payload.get("portal", {}) if isinstance(stage_payload.get("portal"), dict) else {}
4030
+ for section in portal_payload.get("sections", []):
4031
+ if section.get("source_type") == "view":
4032
+ entity_id = section.get("entity_id")
4033
+ view_id = section.get("view_id")
4034
+ entity = entity_map.get(entity_id) if isinstance(entity_id, str) else None
4035
+ known_view_ids = {
4036
+ view.get("view_id")
4037
+ for view in (entity.get("views", []) if isinstance(entity, dict) else [])
4038
+ if isinstance(view, dict)
4039
+ }
4040
+ if view_id not in known_view_ids:
4041
+ missing.append(
4042
+ f"Portal section '{section.get('section_id')}' references view '{view_id}' on entity '{entity_id}'. Run solution_build_views first."
4043
+ )
4044
+ return list(dict.fromkeys(missing))
4045
+ if stage_name == "navigation":
4046
+ navigation_payload = stage_payload.get("navigation", {}) if isinstance(stage_payload.get("navigation"), dict) else {}
4047
+ package_targets = [
4048
+ item for item in navigation_payload.get("items", [])
4049
+ if isinstance(item, dict) and item.get("target_type") == "package"
4050
+ ]
4051
+ if not package_targets:
4052
+ missing.append("Navigation stage must include a package target item as the single published navigation root.")
4053
+ return list(dict.fromkeys(missing))
4054
+ return missing