@josephyan/qingflow-cli 0.2.0-beta.55

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