@josephyan/qingflow-app-builder-mcp 0.1.0-beta.9

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 (55) hide show
  1. package/README.md +21 -0
  2. package/docs/local-agent-install.md +228 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +33 -0
  8. package/pyproject.toml +64 -0
  9. package/qingflow-app-builder-mcp +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 +336 -0
  13. package/src/qingflow_mcp/config.py +182 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/list_type_labels.py +52 -0
  17. package/src/qingflow_mcp/server.py +70 -0
  18. package/src/qingflow_mcp/server_app_builder.py +352 -0
  19. package/src/qingflow_mcp/server_app_user.py +334 -0
  20. package/src/qingflow_mcp/session_store.py +249 -0
  21. package/src/qingflow_mcp/solution/__init__.py +6 -0
  22. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  23. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  24. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  26. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  27. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  28. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  29. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  30. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  31. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  32. package/src/qingflow_mcp/solution/design_session.py +222 -0
  33. package/src/qingflow_mcp/solution/design_store.py +100 -0
  34. package/src/qingflow_mcp/solution/executor.py +2065 -0
  35. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  36. package/src/qingflow_mcp/solution/run_store.py +221 -0
  37. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  38. package/src/qingflow_mcp/tools/__init__.py +1 -0
  39. package/src/qingflow_mcp/tools/app_tools.py +406 -0
  40. package/src/qingflow_mcp/tools/approval_tools.py +498 -0
  41. package/src/qingflow_mcp/tools/auth_tools.py +514 -0
  42. package/src/qingflow_mcp/tools/base.py +81 -0
  43. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  44. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  45. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  46. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  47. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  48. package/src/qingflow_mcp/tools/qingbi_report_tools.py +235 -0
  49. package/src/qingflow_mcp/tools/record_tools.py +4307 -0
  50. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  51. package/src/qingflow_mcp/tools/solution_tools.py +2680 -0
  52. package/src/qingflow_mcp/tools/task_tools.py +692 -0
  53. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  54. package/src/qingflow_mcp/tools/workflow_tools.py +238 -0
  55. package/src/qingflow_mcp/tools/workspace_tools.py +170 -0
@@ -0,0 +1,2680 @@
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.run_store import RunArtifactStore, fingerprint_payload
19
+ from ..solution.spec_models import (
20
+ AnalyticsPortalBuildSpec,
21
+ AppBuildSpec,
22
+ AppFlowBuildSpec,
23
+ FlowBuildSpec,
24
+ BuildManifest,
25
+ NavigationBuildSpec,
26
+ SolutionSpec,
27
+ ViewsBuildSpec,
28
+ )
29
+ from .app_tools import AppTools
30
+ from .base import ToolBase
31
+ from .navigation_tools import NavigationTools
32
+ from .package_tools import PackageTools
33
+ from .portal_tools import PortalTools
34
+ from .qingbi_report_tools import QingbiReportTools
35
+ from .record_tools import RecordTools
36
+ from .role_tools import RoleTools
37
+ from .view_tools import ViewTools
38
+ from .workflow_tools import WorkflowTools
39
+ from .workspace_tools import WorkspaceTools
40
+
41
+ STAGED_BUILD_MODES = {"preflight", "plan", "apply", "repair"}
42
+ STAGE_ORDER = ["app_flow", "views", "analytics_portal", "navigation"]
43
+ STAGE_TOOL_NAMES = {
44
+ "app_flow": "solution_build_app_flow",
45
+ "views": "solution_build_views",
46
+ "analytics_portal": "solution_build_analytics_portal",
47
+ "navigation": "solution_build_navigation",
48
+ }
49
+ SOLUTION_SCHEMA_STAGES = {"app", "flow", "views", "analytics_portal", "navigation", "app_flow", "all"}
50
+ SOLUTION_SCHEMA_INTENTS = {"minimal", "full", "demo"}
51
+
52
+
53
+ class SolutionTools(ToolBase):
54
+ def register(self, mcp: FastMCP) -> None:
55
+ @mcp.tool()
56
+ def solution_design_session(
57
+ action: str = "get",
58
+ session_id: str = "",
59
+ stage: str | None = None,
60
+ design_patch: dict[str, Any] | None = None,
61
+ metadata: dict[str, Any] | None = None,
62
+ ) -> dict[str, Any]:
63
+ return self.solution_design_session(
64
+ action=action,
65
+ session_id=session_id,
66
+ stage=stage,
67
+ design_patch=design_patch or {},
68
+ metadata=metadata or {},
69
+ )
70
+
71
+ @mcp.tool()
72
+ def solution_build_all(
73
+ profile: str = DEFAULT_PROFILE,
74
+ mode: str = "plan",
75
+ build_id: str = "",
76
+ solution_spec: dict[str, Any] | None = None,
77
+ publish: bool = True,
78
+ run_label: str | None = None,
79
+ target: dict[str, Any] | None = None,
80
+ repair_patch: dict[str, Any] | None = None,
81
+ verify: bool = False,
82
+ ) -> dict[str, Any]:
83
+ return self.solution_build_all(
84
+ profile=profile,
85
+ mode=mode,
86
+ build_id=build_id,
87
+ solution_spec=solution_spec or {},
88
+ publish=publish,
89
+ run_label=run_label,
90
+ target=target or {},
91
+ repair_patch=repair_patch or {},
92
+ verify=verify,
93
+ )
94
+
95
+ @mcp.tool()
96
+ def solution_build_app(
97
+ profile: str = DEFAULT_PROFILE,
98
+ mode: str = "plan",
99
+ build_id: str = "",
100
+ app_spec: dict[str, Any] | None = None,
101
+ publish: bool = True,
102
+ run_label: str | None = None,
103
+ target: dict[str, Any] | None = None,
104
+ repair_patch: dict[str, Any] | None = None,
105
+ ) -> dict[str, Any]:
106
+ return self.solution_build_app(
107
+ profile=profile,
108
+ mode=mode,
109
+ build_id=build_id,
110
+ app_spec=app_spec or {},
111
+ publish=publish,
112
+ run_label=run_label,
113
+ target=target or {},
114
+ repair_patch=repair_patch or {},
115
+ )
116
+
117
+ @mcp.tool()
118
+ def solution_build_flow(
119
+ profile: str = DEFAULT_PROFILE,
120
+ mode: str = "plan",
121
+ build_id: str = "",
122
+ flow_spec: dict[str, Any] | None = None,
123
+ publish: bool = True,
124
+ run_label: str | None = None,
125
+ repair_patch: dict[str, Any] | None = None,
126
+ ) -> dict[str, Any]:
127
+ return self.solution_build_flow(
128
+ profile=profile,
129
+ mode=mode,
130
+ build_id=build_id,
131
+ flow_spec=flow_spec or {},
132
+ publish=publish,
133
+ run_label=run_label,
134
+ repair_patch=repair_patch or {},
135
+ )
136
+
137
+ @mcp.tool()
138
+ def solution_build_app_flow(
139
+ profile: str = DEFAULT_PROFILE,
140
+ mode: str = "plan",
141
+ build_id: str = "",
142
+ app_flow_spec: dict[str, Any] | None = None,
143
+ publish: bool = True,
144
+ run_label: str | None = None,
145
+ target: dict[str, Any] | None = None,
146
+ repair_patch: dict[str, Any] | None = None,
147
+ ) -> dict[str, Any]:
148
+ return self.solution_build_app_flow(
149
+ profile=profile,
150
+ mode=mode,
151
+ build_id=build_id,
152
+ app_flow_spec=app_flow_spec or {},
153
+ publish=publish,
154
+ run_label=run_label,
155
+ target=target or {},
156
+ repair_patch=repair_patch or {},
157
+ )
158
+
159
+ @mcp.tool()
160
+ def solution_schema_example(
161
+ stage: str = "app",
162
+ intent: str = "minimal",
163
+ ) -> dict[str, Any]:
164
+ return self.solution_schema_example(stage=stage, intent=intent)
165
+
166
+ @mcp.tool()
167
+ def solution_build_views(
168
+ profile: str = DEFAULT_PROFILE,
169
+ mode: str = "plan",
170
+ build_id: str = "",
171
+ views_spec: dict[str, Any] | None = None,
172
+ publish: bool = True,
173
+ run_label: str | None = None,
174
+ repair_patch: dict[str, Any] | None = None,
175
+ ) -> dict[str, Any]:
176
+ return self.solution_build_views(
177
+ profile=profile,
178
+ mode=mode,
179
+ build_id=build_id,
180
+ views_spec=views_spec or {},
181
+ publish=publish,
182
+ run_label=run_label,
183
+ repair_patch=repair_patch or {},
184
+ )
185
+
186
+ @mcp.tool()
187
+ def solution_build_analytics_portal(
188
+ profile: str = DEFAULT_PROFILE,
189
+ mode: str = "plan",
190
+ build_id: str = "",
191
+ analytics_portal_spec: dict[str, Any] | None = None,
192
+ publish: bool = True,
193
+ run_label: str | None = None,
194
+ repair_patch: dict[str, Any] | None = None,
195
+ ) -> dict[str, Any]:
196
+ return self.solution_build_analytics_portal(
197
+ profile=profile,
198
+ mode=mode,
199
+ build_id=build_id,
200
+ analytics_portal_spec=analytics_portal_spec or {},
201
+ publish=publish,
202
+ run_label=run_label,
203
+ repair_patch=repair_patch or {},
204
+ )
205
+
206
+ @mcp.tool()
207
+ def solution_build_navigation(
208
+ profile: str = DEFAULT_PROFILE,
209
+ mode: str = "plan",
210
+ build_id: str = "",
211
+ navigation_spec: dict[str, Any] | None = None,
212
+ publish: bool = True,
213
+ run_label: str | None = None,
214
+ repair_patch: dict[str, Any] | None = None,
215
+ ) -> dict[str, Any]:
216
+ return self.solution_build_navigation(
217
+ profile=profile,
218
+ mode=mode,
219
+ build_id=build_id,
220
+ navigation_spec=navigation_spec or {},
221
+ publish=publish,
222
+ run_label=run_label,
223
+ repair_patch=repair_patch or {},
224
+ )
225
+
226
+ @mcp.tool()
227
+ def solution_build_status(build_id: str = "") -> dict[str, Any]:
228
+ return self.solution_build_status(build_id=build_id)
229
+
230
+ def solution_design_session(
231
+ self,
232
+ *,
233
+ action: str,
234
+ session_id: str,
235
+ stage: str | None,
236
+ design_patch: dict[str, Any],
237
+ metadata: dict[str, Any],
238
+ ) -> dict[str, Any]:
239
+ try:
240
+ if action == "start" and not session_id:
241
+ session_id = _generate_session_id(metadata=metadata, stage=stage)
242
+ if action not in {"start", "get", "submit_stage", "finalize"}:
243
+ return self._design_failure_response(session_id, action, "config", "action must be one of: start, get, submit_stage, finalize")
244
+ if not session_id:
245
+ return self._design_failure_response(session_id, action, "config", "session_id is required")
246
+ store = DesignSessionStore.open(session_id=session_id, metadata=metadata)
247
+ if metadata:
248
+ store.set_metadata(metadata)
249
+
250
+ if action == "start":
251
+ evaluation = evaluate_design_session(store.data.get("stage_payloads", {}))
252
+ store.update_progress(
253
+ status=evaluation["status"],
254
+ current_stage=evaluation["current_stage"],
255
+ stage_results=evaluation["stage_results"],
256
+ merged_design_spec=evaluation["merged_design_spec"],
257
+ )
258
+ return self._design_summary(store)
259
+
260
+ if action == "get":
261
+ return self._design_summary(store)
262
+
263
+ if action == "submit_stage":
264
+ if stage not in {item.value for item in DesignStage if item != DesignStage.finalize}:
265
+ return self._design_failure_response(session_id, action, "config", "stage must be one of: discover, design, experience")
266
+ if not isinstance(design_patch, dict) or not design_patch:
267
+ return self._design_failure_response(session_id, action, "config", "design_patch must be a non-empty object")
268
+ existing_payload = store.get_stage_payload(stage)
269
+ merged_payload = merge_design_payload(existing_payload, design_patch)
270
+ store.set_stage_payload(stage, merged_payload)
271
+ evaluation = evaluate_design_session(store.data.get("stage_payloads", {}))
272
+ store.update_progress(
273
+ status=evaluation["status"],
274
+ current_stage=evaluation["current_stage"],
275
+ stage_results=evaluation["stage_results"],
276
+ merged_design_spec=evaluation["merged_design_spec"],
277
+ )
278
+ return self._design_summary(store)
279
+
280
+ finalized = finalize_design_session(store.data.get("stage_payloads", {}))
281
+ store.update_progress(
282
+ status=finalized["status"],
283
+ current_stage=finalized["current_stage"],
284
+ stage_results=finalized["stage_results"],
285
+ merged_design_spec=finalized["merged_design_spec"],
286
+ )
287
+ store.mark_finalized(
288
+ normalized_solution_spec=finalized["normalized_solution_spec"],
289
+ execution_plan=finalized["execution_plan"],
290
+ )
291
+ return self._design_summary(store)
292
+ except ValidationError as exc:
293
+ return self._design_failure_response(session_id, action, "validation", exc.errors())
294
+ except ValueError as exc:
295
+ return self._design_failure_response(session_id, action, "config", str(exc))
296
+ except Exception as exc: # noqa: BLE001
297
+ return self._design_failure_response(session_id, action, "runtime", str(exc))
298
+
299
+ def solution_bootstrap(
300
+ self,
301
+ *,
302
+ profile: str,
303
+ mode: str,
304
+ solution_spec: dict[str, Any],
305
+ idempotency_key: str,
306
+ publish: bool,
307
+ run_label: str | None,
308
+ target: dict[str, Any],
309
+ repair_patch: dict[str, Any],
310
+ ) -> dict[str, Any]:
311
+ if mode not in {"preflight", "plan", "apply", "repair"}:
312
+ return self._failure_response(mode, idempotency_key, "config", "mode must be one of: preflight, plan, apply, repair")
313
+ if not idempotency_key:
314
+ return self._failure_response(mode, idempotency_key, "config", "idempotency_key is required")
315
+ if not isinstance(solution_spec, dict) or not solution_spec:
316
+ return self._failure_response(mode, idempotency_key, "config", "solution_spec must be a non-empty object")
317
+ if mode != "repair" and repair_patch:
318
+ return self._failure_response(mode, idempotency_key, "config", "repair_patch is only supported in repair mode")
319
+
320
+ def runner() -> dict[str, Any]:
321
+ try:
322
+ base_spec_payload = deepcopy(solution_spec)
323
+ if mode == "repair" and repair_patch:
324
+ base_spec_payload = deep_merge(base_spec_payload, repair_patch)
325
+ parsed = SolutionSpec.model_validate(base_spec_payload)
326
+ normalized = normalize_solution_spec(parsed)
327
+ compiled = compile_solution(normalized)
328
+ package_tag_id = _resolve_target_package_tag_id(target)
329
+ if package_tag_id is not None:
330
+ compiled = _bind_existing_package_target(compiled, package_tag_id)
331
+ request_fingerprint = fingerprint_payload(
332
+ {
333
+ "mode": "apply" if mode == "repair" else mode,
334
+ "publish": publish,
335
+ "solution_spec": normalized.model_dump(mode="json"),
336
+ "target": target,
337
+ }
338
+ )
339
+ if mode in {"preflight", "plan"}:
340
+ store = BuildAssemblyStore.open(build_id=idempotency_key)
341
+ store.set_manifest(normalized.model_dump(mode="json"))
342
+ return {
343
+ "build_id": idempotency_key,
344
+ "mode": mode,
345
+ "idempotency_key": idempotency_key,
346
+ "normalized_solution_spec": normalized.model_dump(mode="json"),
347
+ "execution_plan": compiled.execution_plan.as_dict(),
348
+ "artifacts": {},
349
+ "step_results": {},
350
+ "publish_results": {},
351
+ "status": "preflighted" if mode == "preflight" else "planned",
352
+ "errors": [],
353
+ "run_path": None,
354
+ "build_path": str(store.path),
355
+ "suggested_next_call": _solution_build_next_call(
356
+ tool_name="solution_build_all",
357
+ mode=mode,
358
+ build_id=idempotency_key,
359
+ ),
360
+ }
361
+
362
+ store = RunArtifactStore.open(
363
+ idempotency_key=idempotency_key,
364
+ normalized_solution_spec=normalized.model_dump(mode="json"),
365
+ request_fingerprint=request_fingerprint,
366
+ run_label=run_label,
367
+ )
368
+ if mode == "apply":
369
+ store.ensure_apply_fingerprint(request_fingerprint)
370
+ if package_tag_id is not None:
371
+ store.set_artifact("package", "tag_id", package_tag_id)
372
+ store.set_artifact("package", "reused", True)
373
+ result = self._build_executor().execute(profile=profile, compiled=compiled, store=store, publish=publish, mode=mode)
374
+ return {
375
+ "mode": mode,
376
+ "idempotency_key": idempotency_key,
377
+ "normalized_solution_spec": normalized.model_dump(mode="json"),
378
+ "execution_plan": compiled.execution_plan.as_dict(),
379
+ "artifacts": result["artifacts"],
380
+ "step_results": result["step_results"],
381
+ "publish_results": _extract_publish_results(result["step_results"]),
382
+ "status": result["status"],
383
+ "errors": result["errors"],
384
+ "run_path": result["run_path"],
385
+ }
386
+ except ValidationError as exc:
387
+ return self._failure_response(mode, idempotency_key, "validation", exc.errors())
388
+ except ValueError as exc:
389
+ return self._failure_response(mode, idempotency_key, "config", str(exc))
390
+ except Exception as exc: # noqa: BLE001
391
+ return self._failure_response(mode, idempotency_key, "runtime", str(exc))
392
+
393
+ if mode in {"preflight", "plan"}:
394
+ return runner()
395
+ return self._run(profile, lambda _session_profile, _context: runner())
396
+
397
+ def solution_build_app_flow(
398
+ self,
399
+ *,
400
+ profile: str,
401
+ mode: str,
402
+ build_id: str,
403
+ app_flow_spec: dict[str, Any],
404
+ publish: bool,
405
+ run_label: str | None,
406
+ target: dict[str, Any],
407
+ repair_patch: dict[str, Any],
408
+ ) -> dict[str, Any]:
409
+ return self._stage_build(
410
+ profile=profile,
411
+ mode=mode,
412
+ build_id=build_id,
413
+ stage_name="app_flow",
414
+ stage_payload=app_flow_spec,
415
+ stage_model=AppFlowBuildSpec,
416
+ publish=publish,
417
+ run_label=run_label,
418
+ target=target,
419
+ repair_patch=repair_patch,
420
+ )
421
+
422
+ def solution_build_app(
423
+ self,
424
+ *,
425
+ profile: str,
426
+ mode: str,
427
+ build_id: str,
428
+ app_spec: dict[str, Any],
429
+ publish: bool,
430
+ run_label: str | None,
431
+ target: dict[str, Any],
432
+ repair_patch: dict[str, Any],
433
+ ) -> dict[str, Any]:
434
+ return self._stage_build(
435
+ profile=profile,
436
+ mode=mode,
437
+ build_id=build_id,
438
+ stage_name="app_flow",
439
+ stage_payload=app_spec,
440
+ stage_model=AppBuildSpec,
441
+ publish=publish,
442
+ run_label=run_label,
443
+ target=target,
444
+ repair_patch=repair_patch,
445
+ public_stage_name="app",
446
+ tool_name="solution_build_app",
447
+ projection_name="app",
448
+ )
449
+
450
+ def solution_build_flow(
451
+ self,
452
+ *,
453
+ profile: str,
454
+ mode: str,
455
+ build_id: str,
456
+ flow_spec: dict[str, Any],
457
+ publish: bool,
458
+ run_label: str | None,
459
+ repair_patch: dict[str, Any],
460
+ ) -> dict[str, Any]:
461
+ return self._stage_build(
462
+ profile=profile,
463
+ mode=mode,
464
+ build_id=build_id,
465
+ stage_name="app_flow",
466
+ stage_payload=flow_spec,
467
+ stage_model=FlowBuildSpec,
468
+ publish=publish,
469
+ run_label=run_label,
470
+ target={},
471
+ repair_patch=repair_patch,
472
+ public_stage_name="flow",
473
+ tool_name="solution_build_flow",
474
+ projection_name="flow",
475
+ )
476
+
477
+ def solution_build_all(
478
+ self,
479
+ *,
480
+ profile: str,
481
+ mode: str,
482
+ build_id: str,
483
+ solution_spec: dict[str, Any],
484
+ publish: bool,
485
+ run_label: str | None,
486
+ target: dict[str, Any],
487
+ repair_patch: dict[str, Any],
488
+ verify: bool,
489
+ ) -> dict[str, Any]:
490
+ if mode in {"preflight", "plan"} and not build_id:
491
+ build_id = _generate_build_id(run_label=run_label, stage_name="all")
492
+ if mode not in STAGED_BUILD_MODES:
493
+ return self._all_failure_response(build_id, mode, "config", "mode must be one of: preflight, plan, apply, repair")
494
+ if not build_id:
495
+ return self._all_failure_response(build_id, mode, "config", "build_id is required")
496
+ if not isinstance(solution_spec, dict):
497
+ return self._all_failure_response(build_id, mode, "config", "solution_spec must be an object")
498
+ if mode != "repair" and not solution_spec:
499
+ return self._all_failure_response(build_id, mode, "config", "solution_spec must be a non-empty object")
500
+ if mode != "repair" and repair_patch:
501
+ return self._all_failure_response(build_id, mode, "config", "repair_patch is only supported in repair mode")
502
+
503
+ def runner() -> dict[str, Any]:
504
+ try:
505
+ base_solution_spec = deepcopy(solution_spec)
506
+ if mode == "repair" and not base_solution_spec:
507
+ assembly = BuildAssemblyStore.open(build_id=build_id, create=False)
508
+ base_solution_spec = assembly.get_manifest()
509
+ if not base_solution_spec.get("entities"):
510
+ return self._all_failure_response(
511
+ build_id,
512
+ mode,
513
+ "config",
514
+ "repair requires a stored build manifest or a non-empty solution_spec",
515
+ )
516
+ resolved_solution_spec = self._resolve_full_solution_spec(
517
+ solution_spec=base_solution_spec,
518
+ mode=mode,
519
+ repair_patch=repair_patch,
520
+ )
521
+ stage_payloads = _split_solution_spec_into_stage_payloads(resolved_solution_spec)
522
+ stage_results: dict[str, Any] = {}
523
+ overall_errors: list[dict[str, Any]] = []
524
+ if mode == "preflight":
525
+ overall_status = "preflighted"
526
+ elif mode == "plan":
527
+ overall_status = "planned"
528
+ else:
529
+ overall_status = "success"
530
+
531
+ for stage_name in STAGE_ORDER:
532
+ stage_payload = stage_payloads[stage_name]
533
+ if _stage_payload_is_empty(stage_name, stage_payload):
534
+ assembly = BuildAssemblyStore.open(build_id=build_id)
535
+ assembly.add_stage_history(
536
+ {
537
+ "stage": stage_name,
538
+ "mode": mode,
539
+ "status": "skipped",
540
+ "reason": _stage_skip_reason(stage_name),
541
+ "execution_step_count": 0,
542
+ }
543
+ )
544
+ stage_results[stage_name] = self._stage_skip_response(
545
+ build_id=build_id,
546
+ stage_name=stage_name,
547
+ mode=mode,
548
+ assembly=assembly,
549
+ tool_name=STAGE_TOOL_NAMES.get(stage_name, "solution_build_app_flow"),
550
+ )
551
+ continue
552
+
553
+ if stage_name == "app_flow":
554
+ result = self._stage_build(
555
+ profile=profile,
556
+ mode=mode,
557
+ build_id=build_id,
558
+ stage_name=stage_name,
559
+ stage_payload=stage_payload,
560
+ stage_model=AppFlowBuildSpec,
561
+ publish=publish,
562
+ run_label=run_label,
563
+ target=target,
564
+ repair_patch={},
565
+ )
566
+ elif stage_name == "views":
567
+ result = self._stage_build(
568
+ profile=profile,
569
+ mode=mode,
570
+ build_id=build_id,
571
+ stage_name=stage_name,
572
+ stage_payload=stage_payload,
573
+ stage_model=ViewsBuildSpec,
574
+ publish=publish,
575
+ run_label=run_label,
576
+ target={},
577
+ repair_patch={},
578
+ )
579
+ elif stage_name == "analytics_portal":
580
+ result = self._stage_build(
581
+ profile=profile,
582
+ mode=mode,
583
+ build_id=build_id,
584
+ stage_name=stage_name,
585
+ stage_payload=stage_payload,
586
+ stage_model=AnalyticsPortalBuildSpec,
587
+ publish=publish,
588
+ run_label=run_label,
589
+ target={},
590
+ repair_patch={},
591
+ )
592
+ else:
593
+ result = self._stage_build(
594
+ profile=profile,
595
+ mode=mode,
596
+ build_id=build_id,
597
+ stage_name=stage_name,
598
+ stage_payload=stage_payload,
599
+ stage_model=NavigationBuildSpec,
600
+ publish=publish,
601
+ run_label=run_label,
602
+ target={},
603
+ repair_patch={},
604
+ )
605
+ stage_results[stage_name] = result
606
+ if result.get("status") == "failed":
607
+ overall_status = "failed"
608
+ overall_errors = _sanitize_errors(result.get("errors", []))
609
+ break
610
+ if mode in {"apply", "repair"} and result.get("status") != "success":
611
+ overall_status = str(result.get("status"))
612
+ elif mode == "plan" and result.get("status") != "planned":
613
+ overall_status = str(result.get("status"))
614
+ elif mode == "preflight" and result.get("status") != "preflighted":
615
+ overall_status = str(result.get("status"))
616
+
617
+ assembly = BuildAssemblyStore.open(build_id=build_id)
618
+ if overall_status != "failed" and mode in {"apply", "repair"}:
619
+ assembly.mark_status("success")
620
+ build_summary = _build_summary(assembly)
621
+ artifacts = assembly.get_artifacts()
622
+ verification = None
623
+ if verify and overall_status == "success":
624
+ verification = self._verify_solution_build(
625
+ profile=profile,
626
+ build_id=build_id,
627
+ artifacts=artifacts,
628
+ solution_spec=resolved_solution_spec,
629
+ )
630
+
631
+ return {
632
+ "build_id": build_id,
633
+ "mode": mode,
634
+ "normalized_solution_spec": resolved_solution_spec,
635
+ "stage_results": stage_results,
636
+ "stage_statuses": {stage: str(result.get("status")) for stage, result in stage_results.items()},
637
+ "artifacts": artifacts,
638
+ "status": overall_status,
639
+ "errors": overall_errors,
640
+ "verification": verification,
641
+ "build_summary": build_summary,
642
+ "build_path": build_summary.get("build_path"),
643
+ "suggested_next_call": _solution_build_next_call(tool_name="solution_build_all", mode=mode, build_id=build_id),
644
+ }
645
+ except ValidationError as exc:
646
+ return self._all_failure_response(build_id, mode, "validation", exc.errors())
647
+ except FileNotFoundError:
648
+ return self._all_failure_response(
649
+ build_id,
650
+ mode,
651
+ "config",
652
+ "repair requires an existing build_id with a stored build manifest",
653
+ )
654
+ except ValueError as exc:
655
+ return self._all_failure_response(build_id, mode, "config", str(exc))
656
+ except Exception as exc: # noqa: BLE001
657
+ return self._all_failure_response(build_id, mode, "runtime", str(exc))
658
+
659
+ if mode in {"preflight", "plan"}:
660
+ return runner()
661
+ return self._run(profile, lambda _session_profile, _context: runner())
662
+
663
+ def solution_build_views(
664
+ self,
665
+ *,
666
+ profile: str,
667
+ mode: str,
668
+ build_id: str,
669
+ views_spec: dict[str, Any],
670
+ publish: bool,
671
+ run_label: str | None,
672
+ repair_patch: dict[str, Any],
673
+ ) -> dict[str, Any]:
674
+ return self._stage_build(
675
+ profile=profile,
676
+ mode=mode,
677
+ build_id=build_id,
678
+ stage_name="views",
679
+ stage_payload=views_spec,
680
+ stage_model=ViewsBuildSpec,
681
+ publish=publish,
682
+ run_label=run_label,
683
+ target={},
684
+ repair_patch=repair_patch,
685
+ )
686
+
687
+ def solution_schema_example(self, *, stage: str, intent: str) -> dict[str, Any]:
688
+ if stage not in SOLUTION_SCHEMA_STAGES:
689
+ return {
690
+ "stage": stage,
691
+ "intent": intent,
692
+ "status": "failed",
693
+ "errors": [{"category": "config", "detail": f"stage must be one of: {', '.join(sorted(SOLUTION_SCHEMA_STAGES))}"}],
694
+ **_solution_error_fields(
695
+ category="config",
696
+ detail=f"stage must be one of: {', '.join(sorted(SOLUTION_SCHEMA_STAGES))}",
697
+ suggested_next_call=None,
698
+ ),
699
+ }
700
+ if intent not in SOLUTION_SCHEMA_INTENTS:
701
+ return {
702
+ "stage": stage,
703
+ "intent": intent,
704
+ "status": "failed",
705
+ "errors": [{"category": "config", "detail": f"intent must be one of: {', '.join(sorted(SOLUTION_SCHEMA_INTENTS))}"}],
706
+ **_solution_error_fields(
707
+ category="config",
708
+ detail=f"intent must be one of: {', '.join(sorted(SOLUTION_SCHEMA_INTENTS))}",
709
+ suggested_next_call=None,
710
+ ),
711
+ }
712
+ return _solution_schema_example_payload(stage=stage, intent=intent)
713
+
714
+ def solution_build_analytics_portal(
715
+ self,
716
+ *,
717
+ profile: str,
718
+ mode: str,
719
+ build_id: str,
720
+ analytics_portal_spec: dict[str, Any],
721
+ publish: bool,
722
+ run_label: str | None,
723
+ repair_patch: dict[str, Any],
724
+ ) -> dict[str, Any]:
725
+ return self._stage_build(
726
+ profile=profile,
727
+ mode=mode,
728
+ build_id=build_id,
729
+ stage_name="analytics_portal",
730
+ stage_payload=analytics_portal_spec,
731
+ stage_model=AnalyticsPortalBuildSpec,
732
+ publish=publish,
733
+ run_label=run_label,
734
+ target={},
735
+ repair_patch=repair_patch,
736
+ )
737
+
738
+ def solution_build_navigation(
739
+ self,
740
+ *,
741
+ profile: str,
742
+ mode: str,
743
+ build_id: str,
744
+ navigation_spec: dict[str, Any],
745
+ publish: bool,
746
+ run_label: str | None,
747
+ repair_patch: dict[str, Any],
748
+ ) -> dict[str, Any]:
749
+ return self._stage_build(
750
+ profile=profile,
751
+ mode=mode,
752
+ build_id=build_id,
753
+ stage_name="navigation",
754
+ stage_payload=navigation_spec,
755
+ stage_model=NavigationBuildSpec,
756
+ publish=publish,
757
+ run_label=run_label,
758
+ target={},
759
+ repair_patch=repair_patch,
760
+ )
761
+
762
+ def solution_build_status(self, *, build_id: str) -> dict[str, Any]:
763
+ if not build_id:
764
+ error_fields = _solution_error_fields(
765
+ category="config",
766
+ detail="build_id is required",
767
+ suggested_next_call=None,
768
+ )
769
+ return {
770
+ "build_id": build_id,
771
+ "status": "failed",
772
+ "errors": [{"category": "config", "detail": "build_id is required"}],
773
+ "build_path": None,
774
+ **error_fields,
775
+ }
776
+ assembly = BuildAssemblyStore.open(build_id=build_id)
777
+ summary = _build_summary(assembly)
778
+ summary["errors"] = []
779
+ return summary
780
+
781
+ def _stage_build(
782
+ self,
783
+ *,
784
+ profile: str,
785
+ mode: str,
786
+ build_id: str,
787
+ stage_name: str,
788
+ stage_payload: dict[str, Any],
789
+ stage_model: type[BaseModel],
790
+ publish: bool,
791
+ run_label: str | None,
792
+ target: dict[str, Any],
793
+ repair_patch: dict[str, Any],
794
+ public_stage_name: str | None = None,
795
+ tool_name: str | None = None,
796
+ projection_name: str | None = None,
797
+ ) -> dict[str, Any]:
798
+ public_stage_name = public_stage_name or stage_name
799
+ tool_name = tool_name or STAGE_TOOL_NAMES.get(stage_name, "solution_build_app_flow")
800
+ projection_name = projection_name or public_stage_name
801
+ if mode in {"preflight", "plan"} and not build_id:
802
+ build_id = _generate_build_id(run_label=run_label, stage_name=stage_name)
803
+ assembly = BuildAssemblyStore.open(build_id=build_id) if build_id else None
804
+ if mode not in STAGED_BUILD_MODES:
805
+ 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)
806
+ if not build_id:
807
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", "build_id is required", assembly, tool_name=tool_name)
808
+ if mode != "repair" and repair_patch:
809
+ 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)
810
+ if stage_name != "app_flow" and target:
811
+ 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)
812
+
813
+ def runner() -> dict[str, Any]:
814
+ return self._execute_stage_build(
815
+ profile=profile,
816
+ build_id=build_id,
817
+ stage_name=stage_name,
818
+ public_stage_name=public_stage_name,
819
+ mode=mode,
820
+ assembly=assembly,
821
+ stage_payload=stage_payload,
822
+ stage_model=stage_model,
823
+ publish=publish,
824
+ run_label=run_label,
825
+ target=target,
826
+ repair_patch=repair_patch,
827
+ tool_name=tool_name,
828
+ projection_name=projection_name,
829
+ )
830
+
831
+ if mode in {"preflight", "plan"}:
832
+ return runner()
833
+ return self._run(profile, lambda _session_profile, _context: runner())
834
+
835
+ def _execute_stage_build(
836
+ self,
837
+ *,
838
+ profile: str,
839
+ build_id: str,
840
+ stage_name: str,
841
+ public_stage_name: str,
842
+ mode: str,
843
+ assembly: BuildAssemblyStore,
844
+ stage_payload: dict[str, Any],
845
+ stage_model: type[BaseModel],
846
+ publish: bool,
847
+ run_label: str | None,
848
+ target: dict[str, Any],
849
+ repair_patch: dict[str, Any],
850
+ tool_name: str,
851
+ projection_name: str,
852
+ ) -> dict[str, Any]:
853
+ try:
854
+ resolved_stage_payload = self._resolve_stage_payload(
855
+ assembly=assembly,
856
+ stage_name=stage_name,
857
+ mode=mode,
858
+ incoming_stage_payload=stage_payload,
859
+ repair_patch=repair_patch,
860
+ stage_model=stage_model,
861
+ projection_name=projection_name,
862
+ )
863
+ missing_prerequisites = _missing_stage_prerequisites(
864
+ stage_name=public_stage_name,
865
+ assembly=assembly,
866
+ stage_payload=resolved_stage_payload,
867
+ )
868
+ if missing_prerequisites:
869
+ raise ValueError("; ".join(missing_prerequisites))
870
+ merged_manifest = _merge_stage_manifest(
871
+ stage_name=stage_name,
872
+ manifest=assembly.get_manifest(),
873
+ stage_payload=resolved_stage_payload,
874
+ )
875
+ parsed_manifest = BuildManifest.model_validate(merged_manifest)
876
+ normalized_manifest = normalize_solution_spec(parsed_manifest)
877
+ compiled = compile_solution(normalized_manifest)
878
+ package_tag_id = _resolve_target_package_tag_id(target)
879
+ if package_tag_id is not None:
880
+ compiled = _bind_existing_package_target(compiled, package_tag_id)
881
+ filtered_compiled = _filter_compiled_solution(
882
+ compiled,
883
+ stage_name=stage_name,
884
+ stage_payload=resolved_stage_payload,
885
+ stage_variant=public_stage_name,
886
+ )
887
+ assembly.set_stage_spec(stage_name, resolved_stage_payload)
888
+ assembly.set_manifest(normalized_manifest.model_dump(mode="json"))
889
+
890
+ if mode in {"preflight", "plan"}:
891
+ status = "preflighted" if mode == "preflight" else "planned"
892
+ assembly.add_stage_history(
893
+ {
894
+ "stage": stage_name,
895
+ "mode": mode,
896
+ "status": status,
897
+ "execution_step_count": len(filtered_compiled.execution_plan.steps),
898
+ }
899
+ )
900
+ assembly.mark_status("draft")
901
+ return self._stage_success_response(
902
+ build_id=build_id,
903
+ stage_name=public_stage_name,
904
+ mode=mode,
905
+ normalized_manifest=normalized_manifest,
906
+ compiled=filtered_compiled,
907
+ artifacts=assembly.get_artifacts(),
908
+ step_results={},
909
+ errors=[],
910
+ run_path=None,
911
+ build_path=str(assembly.path),
912
+ status=status,
913
+ build_summary=_build_summary(assembly),
914
+ tool_name=tool_name,
915
+ )
916
+
917
+ request_fingerprint = fingerprint_payload(
918
+ {
919
+ "stage": stage_name,
920
+ "mode": "apply" if mode == "repair" else mode,
921
+ "publish": publish,
922
+ "build_manifest": normalized_manifest.model_dump(mode="json"),
923
+ "stage_payload": resolved_stage_payload,
924
+ "target": target,
925
+ }
926
+ )
927
+ store = RunArtifactStore.open(
928
+ idempotency_key=_stage_run_id(build_id, stage_name),
929
+ normalized_solution_spec=normalized_manifest.model_dump(mode="json"),
930
+ request_fingerprint=request_fingerprint,
931
+ run_label=run_label,
932
+ initial_artifacts=assembly.get_artifacts(),
933
+ )
934
+ if mode == "apply":
935
+ store.ensure_apply_fingerprint(request_fingerprint)
936
+ if package_tag_id is not None:
937
+ store.set_artifact("package", "tag_id", package_tag_id)
938
+ store.set_artifact("package", "reused", True)
939
+ result = self._build_executor().execute(
940
+ profile=profile,
941
+ compiled=filtered_compiled,
942
+ store=store,
943
+ publish=publish,
944
+ mode=mode,
945
+ )
946
+ assembly.set_artifacts(result["artifacts"])
947
+ assembly.add_stage_history(
948
+ {
949
+ "stage": stage_name,
950
+ "mode": mode,
951
+ "status": result["status"],
952
+ "run_path": result["run_path"],
953
+ "execution_step_count": len(filtered_compiled.execution_plan.steps),
954
+ }
955
+ )
956
+ assembly.mark_status(_build_status_for_stage(stage_name, result["status"]))
957
+ return self._stage_success_response(
958
+ build_id=build_id,
959
+ stage_name=public_stage_name,
960
+ mode=mode,
961
+ normalized_manifest=normalized_manifest,
962
+ compiled=filtered_compiled,
963
+ artifacts=result["artifacts"],
964
+ step_results=result["step_results"],
965
+ errors=result["errors"],
966
+ run_path=result["run_path"],
967
+ build_path=str(assembly.path),
968
+ status=result["status"],
969
+ build_summary=_build_summary(assembly),
970
+ tool_name=tool_name,
971
+ )
972
+ except ValidationError as exc:
973
+ return self._stage_failure_response(build_id, public_stage_name, mode, "validation", exc.errors(), assembly, tool_name=tool_name)
974
+ except ValueError as exc:
975
+ return self._stage_failure_response(build_id, public_stage_name, mode, "config", str(exc), assembly, tool_name=tool_name)
976
+ except Exception as exc: # noqa: BLE001
977
+ return self._stage_failure_response(build_id, public_stage_name, mode, "runtime", str(exc), assembly, tool_name=tool_name)
978
+
979
+ def _resolve_stage_payload(
980
+ self,
981
+ *,
982
+ assembly: BuildAssemblyStore,
983
+ stage_name: str,
984
+ mode: str,
985
+ incoming_stage_payload: dict[str, Any],
986
+ repair_patch: dict[str, Any],
987
+ stage_model: type[BaseModel],
988
+ projection_name: str,
989
+ ) -> dict[str, Any]:
990
+ existing_stage_payload = (assembly.data.get("stage_specs", {}) or {}).get(stage_name, {}) or {}
991
+ base_payload: dict[str, Any] = {}
992
+ if mode == "repair":
993
+ base_payload = deepcopy(existing_stage_payload)
994
+ if incoming_stage_payload:
995
+ base_payload = deep_merge(base_payload, incoming_stage_payload)
996
+ if repair_patch:
997
+ base_payload = deep_merge(base_payload, repair_patch)
998
+ if not base_payload:
999
+ raise ValueError(f"{stage_name} stage does not have a stored spec to repair")
1000
+ else:
1001
+ if incoming_stage_payload:
1002
+ base_payload = deepcopy(incoming_stage_payload)
1003
+ elif existing_stage_payload:
1004
+ base_payload = deepcopy(existing_stage_payload)
1005
+ else:
1006
+ raise ValueError(f"{stage_name} stage spec is required")
1007
+ parsed: BaseModel | None = None
1008
+ try:
1009
+ parsed = stage_model.model_validate(base_payload)
1010
+ except ValidationError as original_error:
1011
+ if _looks_like_full_solution_spec(base_payload):
1012
+ parsed = stage_model.model_validate(_project_stage_payload(stage_name=projection_name, solution_spec=base_payload))
1013
+ else:
1014
+ raise original_error
1015
+ resolved = parsed.model_dump(mode="json", exclude_unset=True)
1016
+ if not resolved:
1017
+ raise ValueError(f"{stage_name} stage spec is required")
1018
+ return resolved
1019
+
1020
+ def _resolve_full_solution_spec(
1021
+ self,
1022
+ *,
1023
+ solution_spec: dict[str, Any],
1024
+ mode: str,
1025
+ repair_patch: dict[str, Any],
1026
+ ) -> dict[str, Any]:
1027
+ base_payload = deepcopy(solution_spec)
1028
+ if mode == "repair" and repair_patch:
1029
+ base_payload = deep_merge(base_payload, repair_patch)
1030
+ parsed = SolutionSpec.model_validate(base_payload)
1031
+ return parsed.model_dump(mode="json", exclude_unset=True)
1032
+
1033
+ def _build_executor(self) -> SolutionExecutor:
1034
+ return SolutionExecutor(
1035
+ workspace_tools=WorkspaceTools(self.sessions, self.backend),
1036
+ package_tools=PackageTools(self.sessions, self.backend),
1037
+ role_tools=RoleTools(self.sessions, self.backend),
1038
+ app_tools=AppTools(self.sessions, self.backend),
1039
+ record_tools=RecordTools(self.sessions, self.backend),
1040
+ workflow_tools=WorkflowTools(self.sessions, self.backend),
1041
+ view_tools=ViewTools(self.sessions, self.backend),
1042
+ chart_tools=QingbiReportTools(self.sessions, self.backend),
1043
+ portal_tools=PortalTools(self.sessions, self.backend),
1044
+ navigation_tools=NavigationTools(self.sessions, self.backend),
1045
+ )
1046
+
1047
+ def _verify_solution_build(
1048
+ self,
1049
+ *,
1050
+ profile: str,
1051
+ build_id: str,
1052
+ artifacts: dict[str, Any],
1053
+ solution_spec: dict[str, Any],
1054
+ ) -> dict[str, Any]:
1055
+ verification = {
1056
+ "build_id": build_id,
1057
+ "status": "success",
1058
+ "errors": [],
1059
+ "package": {},
1060
+ "apps": [],
1061
+ "portal": {},
1062
+ "navigation": {},
1063
+ }
1064
+ package_tools = PackageTools(self.sessions, self.backend)
1065
+ app_tools = AppTools(self.sessions, self.backend)
1066
+ portal_tools = PortalTools(self.sessions, self.backend)
1067
+ navigation_tools = NavigationTools(self.sessions, self.backend)
1068
+ record_tools = RecordTools(self.sessions, self.backend)
1069
+ entity_specs = {
1070
+ entity.get("entity_id"): entity
1071
+ for entity in solution_spec.get("entities", [])
1072
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
1073
+ }
1074
+ field_maps_artifact = artifacts.get("field_maps", {}) if isinstance(artifacts.get("field_maps"), dict) else {}
1075
+
1076
+ try:
1077
+ package_artifact = artifacts.get("package", {}) if isinstance(artifacts.get("package"), dict) else {}
1078
+ tag_id = package_artifact.get("tag_id")
1079
+ if isinstance(tag_id, int) and tag_id > 0:
1080
+ package_detail = package_tools.package_get(profile=profile, tag_id=tag_id).get("result") or {}
1081
+ verification["package"] = {
1082
+ "tag_id": tag_id,
1083
+ "name": package_detail.get("tagName"),
1084
+ "item_count": len(package_detail.get("tagItems") or []),
1085
+ }
1086
+ except Exception as exc: # noqa: BLE001
1087
+ _append_verification_error(verification, "package", exc)
1088
+
1089
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
1090
+ records_artifact = artifacts.get("records", {}) if isinstance(artifacts.get("records"), dict) else {}
1091
+ for entity_id, app_info in apps_artifact.items():
1092
+ try:
1093
+ if not isinstance(app_info, dict):
1094
+ continue
1095
+ app_key = app_info.get("app_key")
1096
+ if not isinstance(app_key, str) or not app_key:
1097
+ continue
1098
+ entity_spec = entity_specs.get(entity_id)
1099
+ field_meta = field_maps_artifact.get(entity_id, {}) if isinstance(field_maps_artifact.get(entity_id), dict) else {}
1100
+ base_info = app_tools.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1101
+ record_page = _readback_record_page(
1102
+ record_tools=record_tools,
1103
+ profile=profile,
1104
+ app_key=app_key,
1105
+ entity_spec=entity_spec,
1106
+ field_meta=field_meta,
1107
+ )
1108
+ rows = record_page.get("list") or []
1109
+ returned_rows = _coerce_count(record_page.get("returned_rows"))
1110
+ if returned_rows is None:
1111
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1112
+ backend_reported_total = _coerce_count(record_page.get("backend_reported_total"))
1113
+ effective_count = _coerce_count(record_page.get("effective_count"))
1114
+ if effective_count is None:
1115
+ effective_count = max(backend_reported_total or 0, returned_rows)
1116
+ seeded_records = records_artifact.get(entity_id, {}) if isinstance(records_artifact.get(entity_id), dict) else {}
1117
+ app_verification = {
1118
+ "entity_id": entity_id,
1119
+ "app_key": app_key,
1120
+ "app_name": base_info.get("formTitle") or app_info.get("display_name") or app_info.get("name"),
1121
+ "app_icon": base_info.get("appIcon"),
1122
+ "returned_rows": returned_rows,
1123
+ "effective_count": effective_count,
1124
+ "list_type": _coerce_count(record_page.get("list_type")),
1125
+ "list_type_label": record_page.get("list_type_label"),
1126
+ "seeded_records": len(seeded_records),
1127
+ "sample_titles": _extract_record_titles(rows, entity_spec=entity_spec, field_meta=field_meta),
1128
+ }
1129
+ if backend_reported_total is not None:
1130
+ app_verification["backend_reported_total"] = backend_reported_total
1131
+ verification["apps"].append(app_verification)
1132
+ except Exception as exc: # noqa: BLE001
1133
+ _append_verification_error(verification, f"app:{entity_id}", exc)
1134
+
1135
+ try:
1136
+ portal_artifact = artifacts.get("portal", {}) if isinstance(artifacts.get("portal"), dict) else {}
1137
+ dash_key = portal_artifact.get("dash_key")
1138
+ if isinstance(dash_key, str) and dash_key:
1139
+ published = portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
1140
+ draft = portal_tools.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
1141
+ verification["portal"] = {
1142
+ "dash_key": dash_key,
1143
+ "name": published.get("dashName") or draft.get("dashName") or published.get("name") or draft.get("name"),
1144
+ "published_component_count": _count_portal_components(published),
1145
+ "draft_component_count": _count_portal_components(draft),
1146
+ }
1147
+ except Exception as exc: # noqa: BLE001
1148
+ _append_verification_error(verification, "portal", exc)
1149
+
1150
+ try:
1151
+ navigation_artifact = artifacts.get("navigation", {}) if isinstance(artifacts.get("navigation"), dict) else {}
1152
+ status = navigation_tools.navigation_get_status(profile=profile).get("result") or {}
1153
+ items = navigation_artifact.get("items") or []
1154
+ verification["navigation"] = {
1155
+ "navigation_id": status.get("navigationId") or status.get("id"),
1156
+ "publish_status": status.get("publishStatus"),
1157
+ "item_count": len(items) if isinstance(items, list) else 0,
1158
+ "items": _extract_navigation_items(items),
1159
+ }
1160
+ except Exception as exc: # noqa: BLE001
1161
+ _append_verification_error(verification, "navigation", exc)
1162
+
1163
+ if verification["errors"]:
1164
+ verification["status"] = "partial"
1165
+ return verification
1166
+
1167
+ def _failure_response(self, mode: str, idempotency_key: str, category: str, detail: Any) -> dict[str, Any]:
1168
+ error_fields = _solution_error_fields(
1169
+ category=category,
1170
+ detail=detail,
1171
+ suggested_next_call=None,
1172
+ stage="all",
1173
+ )
1174
+ return {
1175
+ "mode": mode,
1176
+ "idempotency_key": idempotency_key,
1177
+ "normalized_solution_spec": None,
1178
+ "execution_plan": {"steps": []},
1179
+ "artifacts": {},
1180
+ "step_results": {},
1181
+ "publish_results": {},
1182
+ "status": "failed",
1183
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1184
+ "run_path": None,
1185
+ "verification": None,
1186
+ **error_fields,
1187
+ }
1188
+
1189
+ def _stage_failure_response(
1190
+ self,
1191
+ build_id: str,
1192
+ stage_name: str,
1193
+ mode: str,
1194
+ category: str,
1195
+ detail: Any,
1196
+ assembly: BuildAssemblyStore | None,
1197
+ *,
1198
+ tool_name: str,
1199
+ ) -> dict[str, Any]:
1200
+ error_fields = _solution_error_fields(
1201
+ category=category,
1202
+ detail=detail,
1203
+ suggested_next_call=_solution_build_next_call(
1204
+ tool_name=tool_name,
1205
+ mode=mode,
1206
+ build_id=build_id or None,
1207
+ ),
1208
+ stage=stage_name,
1209
+ )
1210
+ return {
1211
+ "build_id": build_id,
1212
+ "stage": stage_name,
1213
+ "mode": mode,
1214
+ "normalized_build_manifest": None,
1215
+ "execution_plan": {"steps": []},
1216
+ "artifacts": assembly.get_artifacts() if assembly is not None else {},
1217
+ "step_results": {},
1218
+ "publish_results": {},
1219
+ "status": "failed",
1220
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1221
+ "build_path": str(assembly.path) if assembly is not None else None,
1222
+ "run_path": None,
1223
+ "build_summary": _build_summary(assembly) if assembly is not None else None,
1224
+ **error_fields,
1225
+ }
1226
+
1227
+ def _stage_skip_response(
1228
+ self,
1229
+ *,
1230
+ build_id: str,
1231
+ stage_name: str,
1232
+ mode: str,
1233
+ assembly: BuildAssemblyStore,
1234
+ tool_name: str,
1235
+ ) -> dict[str, Any]:
1236
+ return {
1237
+ "build_id": build_id,
1238
+ "stage": stage_name,
1239
+ "mode": mode,
1240
+ "normalized_build_manifest": assembly.get_manifest(),
1241
+ "execution_plan": {"steps": []},
1242
+ "artifacts": assembly.get_artifacts(),
1243
+ "step_results": {},
1244
+ "publish_results": {},
1245
+ "status": "skipped",
1246
+ "errors": [],
1247
+ "build_path": str(assembly.path),
1248
+ "run_path": None,
1249
+ "build_summary": _build_summary(assembly),
1250
+ "suggested_next_call": _solution_build_next_call(
1251
+ tool_name=tool_name,
1252
+ mode=mode,
1253
+ build_id=build_id,
1254
+ ),
1255
+ }
1256
+
1257
+ def _stage_success_response(
1258
+ self,
1259
+ *,
1260
+ build_id: str,
1261
+ stage_name: str,
1262
+ mode: str,
1263
+ normalized_manifest: SolutionSpec,
1264
+ compiled: CompiledSolution,
1265
+ artifacts: dict[str, Any],
1266
+ step_results: dict[str, Any],
1267
+ errors: list[dict[str, Any]],
1268
+ run_path: str | None,
1269
+ build_path: str,
1270
+ status: str,
1271
+ build_summary: dict[str, Any],
1272
+ tool_name: str,
1273
+ ) -> dict[str, Any]:
1274
+ return {
1275
+ "build_id": build_id,
1276
+ "stage": stage_name,
1277
+ "mode": mode,
1278
+ "normalized_build_manifest": normalized_manifest.model_dump(mode="json"),
1279
+ "execution_plan": compiled.execution_plan.as_dict(),
1280
+ "artifacts": artifacts,
1281
+ "step_results": step_results,
1282
+ "publish_results": _extract_publish_results(step_results),
1283
+ "status": status,
1284
+ "errors": _sanitize_errors(errors),
1285
+ "build_path": build_path,
1286
+ "run_path": run_path,
1287
+ "build_summary": build_summary,
1288
+ "suggested_next_call": _solution_build_next_call(
1289
+ tool_name=tool_name,
1290
+ mode=mode,
1291
+ build_id=build_id,
1292
+ ),
1293
+ }
1294
+
1295
+ def _design_failure_response(self, session_id: str, action: str, category: str, detail: Any) -> dict[str, Any]:
1296
+ error_fields = _solution_error_fields(
1297
+ category=category,
1298
+ detail=detail,
1299
+ suggested_next_call=_design_session_next_call(action=action, session_id=session_id or None),
1300
+ )
1301
+ return {
1302
+ "session_id": session_id,
1303
+ "status": "failed",
1304
+ "current_stage": None,
1305
+ "next_stage": None,
1306
+ "metadata": {},
1307
+ "stage_results": {},
1308
+ "merged_design_spec": {},
1309
+ "normalized_solution_spec": None,
1310
+ "execution_plan": None,
1311
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1312
+ "session_path": None,
1313
+ **error_fields,
1314
+ }
1315
+
1316
+ def _design_summary(self, store: DesignSessionStore) -> dict[str, Any]:
1317
+ summary = store.summary()
1318
+ current_stage = summary["current_stage"]
1319
+ summary["next_stage"] = None if summary["status"] == "finalized" else current_stage
1320
+ summary["errors"] = []
1321
+ summary["suggested_next_call"] = _design_summary_next_call(summary)
1322
+ return summary
1323
+
1324
+ def _all_failure_response(self, build_id: str, mode: str, category: str, detail: Any) -> dict[str, Any]:
1325
+ error_fields = _solution_error_fields(
1326
+ category=category,
1327
+ detail=detail,
1328
+ suggested_next_call=_solution_build_next_call(tool_name="solution_build_all", mode=mode, build_id=build_id or None),
1329
+ stage="all",
1330
+ )
1331
+ return {
1332
+ "build_id": build_id,
1333
+ "mode": mode,
1334
+ "normalized_solution_spec": None,
1335
+ "stage_results": {},
1336
+ "stage_statuses": {},
1337
+ "artifacts": {},
1338
+ "status": "failed",
1339
+ "errors": _sanitize_errors([{"category": category, "detail": detail}]),
1340
+ "verification": None,
1341
+ "build_summary": None,
1342
+ "build_path": None,
1343
+ **error_fields,
1344
+ }
1345
+
1346
+
1347
+ def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
1348
+ merged = deepcopy(base)
1349
+ for key, value in patch.items():
1350
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
1351
+ merged[key] = deep_merge(merged[key], value)
1352
+ else:
1353
+ merged[key] = deepcopy(value)
1354
+ return merged
1355
+
1356
+
1357
+ def _extract_publish_results(step_results: dict[str, Any]) -> dict[str, Any]:
1358
+ return {
1359
+ step_name: step_result
1360
+ for step_name, step_result in step_results.items()
1361
+ if step_name.startswith("publish.")
1362
+ }
1363
+
1364
+
1365
+ def _sanitize_json_value(value: Any) -> Any:
1366
+ if value is None or isinstance(value, (str, int, float, bool)):
1367
+ return value
1368
+ if isinstance(value, BaseModel):
1369
+ return _sanitize_json_value(value.model_dump(mode="json"))
1370
+ if isinstance(value, dict):
1371
+ return {str(key): _sanitize_json_value(item) for key, item in value.items()}
1372
+ if isinstance(value, (list, tuple, set)):
1373
+ return [_sanitize_json_value(item) for item in value]
1374
+ if isinstance(value, Exception):
1375
+ return str(value)
1376
+ if hasattr(value, "__fspath__"):
1377
+ return str(value)
1378
+ return str(value)
1379
+
1380
+
1381
+ def _sanitize_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
1382
+ sanitized: list[dict[str, Any]] = []
1383
+ for error in errors:
1384
+ payload = _sanitize_json_value(error)
1385
+ if isinstance(payload, dict):
1386
+ sanitized.append(payload)
1387
+ continue
1388
+ sanitized.append({"category": "runtime", "detail": payload})
1389
+ return sanitized
1390
+
1391
+
1392
+ def _generate_build_id(*, run_label: str | None, stage_name: str) -> str:
1393
+ prefix = _slug_fragment(run_label) or stage_name
1394
+ return f"{prefix}-{uuid4().hex[:10]}"
1395
+
1396
+
1397
+ def _generate_session_id(*, metadata: dict[str, Any], stage: str | None) -> str:
1398
+ base = _slug_fragment(str(metadata.get("goal") or metadata.get("task") or metadata.get("run_label") or "")) or stage or "design"
1399
+ return f"{base}-{uuid4().hex[:10]}"
1400
+
1401
+
1402
+ def _slug_fragment(value: str) -> str:
1403
+ text = "".join(ch.lower() if ch.isalnum() else "-" for ch in value).strip("-")
1404
+ compact = "-".join(part for part in text.split("-") if part)
1405
+ return compact[:32].rstrip("-")
1406
+
1407
+
1408
+ def _solution_build_next_call(*, tool_name: str, mode: str, build_id: str | None) -> dict[str, Any] | None:
1409
+ if not build_id:
1410
+ return None
1411
+ if mode not in STAGED_BUILD_MODES:
1412
+ return None
1413
+ if mode == "preflight":
1414
+ next_mode = "plan"
1415
+ elif mode == "plan":
1416
+ next_mode = "apply"
1417
+ else:
1418
+ next_mode = "repair"
1419
+ return {"tool_name": tool_name, "arguments": {"mode": next_mode, "build_id": build_id}}
1420
+
1421
+
1422
+ def _design_session_next_call(*, action: str, session_id: str | None) -> dict[str, Any] | None:
1423
+ if action == "start" and not session_id:
1424
+ return {"tool_name": "solution_design_session", "arguments": {"action": "start"}}
1425
+ if session_id:
1426
+ return {"tool_name": "solution_design_session", "arguments": {"action": "get", "session_id": session_id}}
1427
+ return None
1428
+
1429
+
1430
+ def _design_summary_next_call(summary: dict[str, Any]) -> dict[str, Any] | None:
1431
+ session_id = summary.get("session_id")
1432
+ if not session_id:
1433
+ return None
1434
+ if summary.get("status") == "finalized":
1435
+ return None
1436
+ next_stage = summary.get("next_stage")
1437
+ if next_stage == "finalize":
1438
+ return {"tool_name": "solution_design_session", "arguments": {"action": "finalize", "session_id": session_id}}
1439
+ if next_stage:
1440
+ return {
1441
+ "tool_name": "solution_design_session",
1442
+ "arguments": {"action": "submit_stage", "session_id": session_id, "stage": next_stage},
1443
+ }
1444
+ return {"tool_name": "solution_design_session", "arguments": {"action": "get", "session_id": session_id}}
1445
+
1446
+
1447
+ def _solution_schema_example_payload(*, stage: str, intent: str) -> dict[str, Any]:
1448
+ tool_name, payload_key = _solution_schema_tool_metadata(stage)
1449
+ examples = _solution_schema_examples(stage)
1450
+ selected = deepcopy(examples.get(intent) or examples["minimal"])
1451
+ return {
1452
+ "status": "ok",
1453
+ "stage": stage,
1454
+ "intent": intent,
1455
+ "tool_name": tool_name,
1456
+ "payload_key": payload_key,
1457
+ "required_fields": _solution_schema_required_fields(stage),
1458
+ "optional_fields": _solution_schema_optional_fields(stage),
1459
+ "selected_example": selected,
1460
+ "examples": examples,
1461
+ "common_errors": _solution_schema_common_errors(stage),
1462
+ "notes": _solution_schema_notes(stage),
1463
+ "suggested_next_call": {
1464
+ "tool_name": tool_name,
1465
+ "arguments": {"mode": "preflight", payload_key: selected},
1466
+ },
1467
+ }
1468
+
1469
+
1470
+ def _solution_schema_tool_metadata(stage: str) -> tuple[str, str]:
1471
+ mapping = {
1472
+ "app": ("solution_build_app", "app_spec"),
1473
+ "flow": ("solution_build_flow", "flow_spec"),
1474
+ "views": ("solution_build_views", "views_spec"),
1475
+ "analytics_portal": ("solution_build_analytics_portal", "analytics_portal_spec"),
1476
+ "navigation": ("solution_build_navigation", "navigation_spec"),
1477
+ "app_flow": ("solution_build_app_flow", "app_flow_spec"),
1478
+ "all": ("solution_build_all", "solution_spec"),
1479
+ }
1480
+ return mapping[stage]
1481
+
1482
+
1483
+ def _solution_schema_required_fields(stage: str) -> list[str]:
1484
+ fields = {
1485
+ "app": ["solution_name", "entities[].entity_id", "entities[].display_name", "entities[].kind", "entities[].fields[]"],
1486
+ "flow": ["entities[].entity_id", "entities[].workflow or entities[].lifecycle_stages"],
1487
+ "views": ["entities[].entity_id", "entities[].views[]"],
1488
+ "analytics_portal": ["entities[].entity_id or portal.sections[]"],
1489
+ "navigation": ["navigation.items[]"],
1490
+ "app_flow": ["solution_name", "entities[]"],
1491
+ "all": ["solution_name", "entities[]"],
1492
+ }
1493
+ return fields[stage]
1494
+
1495
+
1496
+ def _solution_schema_optional_fields(stage: str) -> list[str]:
1497
+ fields = {
1498
+ "app": ["summary", "business_context", "package", "roles", "publish_policy", "preferences", "entities[].form_layout", "entities[].sample_records"],
1499
+ "flow": ["solution_name", "roles", "publish_policy", "entities[].status_field_id", "entities[].owner_field_id", "entities[].start_field_id", "entities[].end_field_id"],
1500
+ "views": ["solution_name", "metadata"],
1501
+ "analytics_portal": ["solution_name", "publish_policy", "portal.name", "portal.sections[]"],
1502
+ "navigation": ["solution_name", "publish_policy", "navigation.enabled"],
1503
+ "app_flow": ["summary", "package", "roles", "publish_policy", "preferences"],
1504
+ "all": ["portal", "navigation", "roles", "publish_policy", "preferences", "business_context"],
1505
+ }
1506
+ return fields[stage]
1507
+
1508
+
1509
+ def _solution_schema_common_errors(stage: str) -> list[str]:
1510
+ errors = {
1511
+ "app": [
1512
+ "Use enum values exactly, for example field.type='text' or kind='transaction'.",
1513
+ "Each field_id must be unique within an entity.",
1514
+ "Do not include workflow/views/charts in app_spec. Use solution_build_flow or later stage tools.",
1515
+ ],
1516
+ "flow": [
1517
+ "The target entity must already exist in the build manifest or from a prior solution_build_app run.",
1518
+ "status_field_id must point to an existing field, usually 'status'.",
1519
+ "Do not include fields or form_layout in flow_spec.",
1520
+ ],
1521
+ "views": [
1522
+ "View field_ids must reference existing fields on the entity.",
1523
+ "Run solution_build_app first so the entity exists.",
1524
+ ],
1525
+ "analytics_portal": [
1526
+ "Chart field references must already exist on the entity.",
1527
+ "Portal sections that reference views require solution_build_views to run first.",
1528
+ ],
1529
+ "navigation": [
1530
+ "Navigation must include a package target item as the single published root.",
1531
+ ],
1532
+ "app_flow": [
1533
+ "If app and flow are authored separately, prefer solution_build_app then solution_build_flow.",
1534
+ ],
1535
+ "all": [
1536
+ "Use full solution_spec only when the agent can provide all stages coherently.",
1537
+ ],
1538
+ }
1539
+ return errors[stage]
1540
+
1541
+
1542
+ def _solution_schema_notes(stage: str) -> list[str]:
1543
+ notes = {
1544
+ "app": [
1545
+ "Use solution_build_app for package, entity, field, layout, and sample record design.",
1546
+ "If you are unsure, call solution_schema_example(stage='app', intent='minimal') before generating payloads.",
1547
+ ],
1548
+ "flow": [
1549
+ "Use solution_build_flow only after app/schema exists.",
1550
+ "workflow.nodes uses node_type enum values like start, audit, fill, copy, branch, condition.",
1551
+ ],
1552
+ "views": ["views_spec is purely for view definitions, not fields or workflow."],
1553
+ "analytics_portal": ["analytics_portal_spec may include charts, portal, or both."],
1554
+ "navigation": ["navigation_spec only manages top-level published navigation."],
1555
+ "app_flow": ["solution_build_app_flow remains available for compatibility, but app + flow split is preferred."],
1556
+ "all": ["solution_build_all is best for coherent multi-stage system builds."],
1557
+ }
1558
+ return notes[stage]
1559
+
1560
+
1561
+ def _solution_schema_examples(stage: str) -> dict[str, dict[str, Any]]:
1562
+ examples = {
1563
+ "app": {
1564
+ "minimal": {
1565
+ "solution_name": "Demo Workspace",
1566
+ "entities": [
1567
+ {
1568
+ "entity_id": "demo_request",
1569
+ "display_name": "测试申请",
1570
+ "kind": "transaction",
1571
+ "title_field_id": "title",
1572
+ "fields": [
1573
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
1574
+ ],
1575
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
1576
+ }
1577
+ ],
1578
+ },
1579
+ "full": {
1580
+ "solution_name": "Demo Workspace",
1581
+ "summary": "通用测试应用",
1582
+ "package": {"name": "测试包"},
1583
+ "entities": [
1584
+ {
1585
+ "entity_id": "demo_request",
1586
+ "display_name": "测试申请",
1587
+ "kind": "transaction",
1588
+ "title_field_id": "title",
1589
+ "status_field_id": "status",
1590
+ "fields": [
1591
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
1592
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
1593
+ {"field_id": "owner", "label": "负责人", "type": "member"},
1594
+ {"field_id": "amount", "label": "金额", "type": "amount"},
1595
+ ],
1596
+ "form_layout": {
1597
+ "sections": [
1598
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "status"]}, {"field_ids": ["owner", "amount"]}]}
1599
+ ]
1600
+ },
1601
+ "sample_records": [{"record_id": "demo-1", "values": {"title": "测试记录", "status": "待处理", "amount": 1000}}],
1602
+ }
1603
+ ],
1604
+ },
1605
+ "demo": {
1606
+ "solution_name": "全字段演示",
1607
+ "package": {"name": "字段演示"},
1608
+ "entities": [
1609
+ {
1610
+ "entity_id": "field_demo",
1611
+ "display_name": "字段演示",
1612
+ "kind": "transaction",
1613
+ "title_field_id": "title",
1614
+ "fields": [
1615
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
1616
+ {"field_id": "description", "label": "说明", "type": "long_text"},
1617
+ {"field_id": "amount", "label": "金额", "type": "amount"},
1618
+ {"field_id": "due_at", "label": "截止时间", "type": "datetime"},
1619
+ {"field_id": "assignee", "label": "处理人", "type": "member"},
1620
+ {"field_id": "team", "label": "所属部门", "type": "department"},
1621
+ {"field_id": "category", "label": "类型", "type": "single_select", "options": ["A", "B", "C"]},
1622
+ {"field_id": "tags", "label": "标签", "type": "multi_select", "options": ["高优先级", "新建", "内部"]},
1623
+ {"field_id": "phone", "label": "手机号", "type": "phone"},
1624
+ {"field_id": "email", "label": "邮箱", "type": "email"},
1625
+ {"field_id": "attachment", "label": "附件", "type": "attachment"},
1626
+ {"field_id": "enabled", "label": "启用", "type": "boolean"},
1627
+ ],
1628
+ "form_layout": {
1629
+ "sections": [
1630
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "category"]}, {"field_ids": ["description"]}]},
1631
+ {"section_id": "coordination", "title": "协同", "rows": [{"field_ids": ["assignee", "team"]}, {"field_ids": ["due_at", "amount"]}]},
1632
+ {"section_id": "contact", "title": "联系与附件", "rows": [{"field_ids": ["phone", "email"]}, {"field_ids": ["attachment", "enabled"]}]},
1633
+ ]
1634
+ },
1635
+ }
1636
+ ],
1637
+ },
1638
+ },
1639
+ "flow": {
1640
+ "minimal": {
1641
+ "solution_name": "Demo Workspace",
1642
+ "entities": [
1643
+ {
1644
+ "entity_id": "demo_request",
1645
+ "status_field_id": "status",
1646
+ "workflow": {
1647
+ "enabled": True,
1648
+ "nodes": [
1649
+ {"node_id": "approve", "name": "审批", "node_type": "audit"},
1650
+ ],
1651
+ },
1652
+ }
1653
+ ],
1654
+ },
1655
+ "full": {
1656
+ "solution_name": "Demo Workspace",
1657
+ "roles": [{"role_id": "approver", "name": "审批人"}],
1658
+ "entities": [
1659
+ {
1660
+ "entity_id": "demo_request",
1661
+ "status_field_id": "status",
1662
+ "owner_field_id": "owner",
1663
+ "workflow": {
1664
+ "enabled": True,
1665
+ "nodes": [
1666
+ {"node_id": "start", "name": "发起", "node_type": "start"},
1667
+ {"node_id": "approve", "name": "直属审批", "node_type": "audit", "assignees": {"roles": ["approver"]}},
1668
+ ],
1669
+ },
1670
+ }
1671
+ ],
1672
+ },
1673
+ "demo": {
1674
+ "solution_name": "Demo Workspace",
1675
+ "entities": [
1676
+ {
1677
+ "entity_id": "field_demo",
1678
+ "status_field_id": "status",
1679
+ "workflow": {
1680
+ "enabled": True,
1681
+ "nodes": [
1682
+ {"node_id": "start", "name": "发起", "node_type": "start"},
1683
+ {"node_id": "manager_review", "name": "经理审批", "node_type": "audit"},
1684
+ {"node_id": "copy_notice", "name": "结果抄送", "node_type": "copy"},
1685
+ ],
1686
+ },
1687
+ }
1688
+ ],
1689
+ },
1690
+ },
1691
+ "views": {
1692
+ "minimal": {
1693
+ "solution_name": "Demo Workspace",
1694
+ "entities": [
1695
+ {"entity_id": "demo_request", "views": [{"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title"], "being_default": True}]}
1696
+ ],
1697
+ },
1698
+ "full": {
1699
+ "solution_name": "Demo Workspace",
1700
+ "entities": [
1701
+ {
1702
+ "entity_id": "demo_request",
1703
+ "views": [
1704
+ {"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title", "status", "owner"], "being_default": True},
1705
+ {"view_id": "board", "name": "看板", "type": "board", "field_ids": ["title", "status"], "group_by_field_id": "status"},
1706
+ ],
1707
+ }
1708
+ ],
1709
+ },
1710
+ "demo": {
1711
+ "solution_name": "Demo Workspace",
1712
+ "entities": [
1713
+ {
1714
+ "entity_id": "field_demo",
1715
+ "views": [
1716
+ {"view_id": "table", "name": "字段表格", "type": "table", "field_ids": ["title", "category", "assignee"], "being_default": True},
1717
+ {"view_id": "card", "name": "卡片", "type": "card", "field_ids": ["title", "category", "due_at"]},
1718
+ ],
1719
+ }
1720
+ ],
1721
+ },
1722
+ },
1723
+ "analytics_portal": {
1724
+ "minimal": {
1725
+ "solution_name": "Demo Workspace",
1726
+ "entities": [{"entity_id": "demo_request", "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}]}],
1727
+ },
1728
+ "full": {
1729
+ "solution_name": "Demo Workspace",
1730
+ "entities": [{"entity_id": "demo_request", "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}]}],
1731
+ "portal": {"name": "Demo 首页", "sections": [{"section_id": "total", "title": "总量", "source_type": "chart", "entity_id": "demo_request", "chart_id": "total"}]},
1732
+ },
1733
+ "demo": {
1734
+ "solution_name": "Demo Workspace",
1735
+ "entities": [{"entity_id": "field_demo", "charts": [{"chart_id": "by_category", "name": "按类型统计", "chart_type": "pie", "dimension_field_ids": ["category"], "config": {"aggregate": "count"}}]}],
1736
+ "portal": {"name": "字段演示首页", "sections": [{"section_id": "category_chart", "title": "类型分布", "source_type": "chart", "entity_id": "field_demo", "chart_id": "by_category"}]},
1737
+ },
1738
+ },
1739
+ "navigation": {
1740
+ "minimal": {"solution_name": "Demo Workspace", "navigation": {"items": [{"item_id": "package_root", "title": "Demo Workspace", "target_type": "package"}]}},
1741
+ "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"}]}},
1742
+ "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"}]}},
1743
+ },
1744
+ "app_flow": {
1745
+ "minimal": {
1746
+ "solution_name": "Demo Workspace",
1747
+ "entities": [
1748
+ {
1749
+ "entity_id": "demo_request",
1750
+ "display_name": "测试申请",
1751
+ "kind": "transaction",
1752
+ "title_field_id": "title",
1753
+ "status_field_id": "status",
1754
+ "fields": [
1755
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
1756
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "已完成"]},
1757
+ ],
1758
+ "form_layout": {"rows": [{"field_ids": ["title", "status"]}]},
1759
+ "workflow": {"enabled": True, "nodes": [{"node_id": "approve", "name": "审批", "node_type": "audit"}]},
1760
+ }
1761
+ ],
1762
+ },
1763
+ "full": {},
1764
+ "demo": {},
1765
+ },
1766
+ "all": {
1767
+ "minimal": {
1768
+ "solution_name": "Demo Workspace",
1769
+ "entities": [
1770
+ {
1771
+ "entity_id": "demo_request",
1772
+ "display_name": "测试申请",
1773
+ "kind": "transaction",
1774
+ "title_field_id": "title",
1775
+ "fields": [{"field_id": "title", "label": "标题", "type": "text", "required": True}],
1776
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
1777
+ }
1778
+ ],
1779
+ },
1780
+ "full": {
1781
+ "solution_name": "Demo Workspace",
1782
+ "package": {"name": "Demo Package"},
1783
+ "entities": [
1784
+ {
1785
+ "entity_id": "demo_request",
1786
+ "display_name": "测试申请",
1787
+ "kind": "transaction",
1788
+ "title_field_id": "title",
1789
+ "status_field_id": "status",
1790
+ "fields": [
1791
+ {"field_id": "title", "label": "标题", "type": "text", "required": True},
1792
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
1793
+ ],
1794
+ "form_layout": {"rows": [{"field_ids": ["title", "status"]}]},
1795
+ "workflow": {"enabled": True, "nodes": [{"node_id": "approve", "name": "审批", "node_type": "audit"}]},
1796
+ "views": [{"view_id": "table", "name": "表格", "type": "table", "field_ids": ["title", "status"], "being_default": True}],
1797
+ "charts": [{"chart_id": "total", "name": "总量", "chart_type": "target", "indicator_field_ids": ["title"], "config": {"aggregate": "count"}}],
1798
+ }
1799
+ ],
1800
+ "portal": {"name": "Demo 首页", "sections": [{"section_id": "total", "title": "总量", "source_type": "chart", "entity_id": "demo_request", "chart_id": "total"}]},
1801
+ "navigation": {"items": [{"item_id": "package_root", "title": "Demo Workspace", "target_type": "package"}]},
1802
+ },
1803
+ "demo": {},
1804
+ },
1805
+ }
1806
+ if not examples["app_flow"]["full"]:
1807
+ examples["app_flow"]["full"] = deepcopy(examples["all"]["full"])
1808
+ if not examples["app_flow"]["demo"]:
1809
+ examples["app_flow"]["demo"] = deepcopy(examples["app"]["demo"])
1810
+ examples["app_flow"]["demo"]["entities"][0]["status_field_id"] = "status"
1811
+ examples["app_flow"]["demo"]["entities"][0]["fields"].append(
1812
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]}
1813
+ )
1814
+ examples["app_flow"]["demo"]["entities"][0]["workflow"] = {
1815
+ "enabled": True,
1816
+ "nodes": [
1817
+ {"node_id": "start", "name": "发起", "node_type": "start"},
1818
+ {"node_id": "approve", "name": "审批", "node_type": "audit"},
1819
+ ],
1820
+ }
1821
+ if not examples["all"]["demo"]:
1822
+ examples["all"]["demo"] = deepcopy(examples["all"]["full"])
1823
+ return examples[stage]
1824
+
1825
+
1826
+ def _solution_error_fields(
1827
+ *,
1828
+ category: str,
1829
+ detail: Any,
1830
+ suggested_next_call: dict[str, Any] | None,
1831
+ stage: str | None = None,
1832
+ ) -> dict[str, Any]:
1833
+ detail_text = str(detail)
1834
+ missing_required_fields: list[str] = []
1835
+ unknown_fields: list[str] = []
1836
+ invalid_field_types: list[str] = []
1837
+ if isinstance(detail, list):
1838
+ for item in detail:
1839
+ if not isinstance(item, dict):
1840
+ continue
1841
+ loc = ".".join(str(part) for part in item.get("loc", []))
1842
+ err_type = str(item.get("type", ""))
1843
+ if err_type.endswith("missing") and loc:
1844
+ missing_required_fields.append(loc)
1845
+ if err_type == "extra_forbidden" and loc:
1846
+ unknown_fields.append(loc)
1847
+ if (err_type.endswith("_type") or err_type in {"enum", "literal_error", "model_type", "list_type", "dict_type"}) and loc:
1848
+ invalid_field_types.append(loc)
1849
+ if "build_id is required" in detail_text:
1850
+ missing_required_fields.append("build_id")
1851
+ if "session_id is required" in detail_text:
1852
+ missing_required_fields.append("session_id")
1853
+ if "solution_spec must be a non-empty object" in detail_text:
1854
+ missing_required_fields.append("solution_spec")
1855
+ if "design_patch must be a non-empty object" in detail_text:
1856
+ missing_required_fields.append("design_patch")
1857
+ recoverable = category in {"config", "validation"}
1858
+ error_code = f"SOLUTION_{category.upper()}"
1859
+ normalized_stage = "app" if stage == "app_flow" else stage
1860
+ suggested_schema_call = None
1861
+ if recoverable and normalized_stage in SOLUTION_SCHEMA_STAGES:
1862
+ suggested_schema_call = {
1863
+ "tool_name": "solution_schema_example",
1864
+ "arguments": {"stage": normalized_stage, "intent": "minimal"},
1865
+ }
1866
+ return {
1867
+ "error_code": error_code,
1868
+ "recoverable": recoverable,
1869
+ "missing_required_fields": sorted(dict.fromkeys(missing_required_fields)),
1870
+ "unknown_fields": sorted(dict.fromkeys(unknown_fields)),
1871
+ "invalid_field_types": sorted(dict.fromkeys(invalid_field_types)),
1872
+ "suggested_next_call": suggested_next_call,
1873
+ "suggested_schema_call": suggested_schema_call,
1874
+ }
1875
+
1876
+
1877
+ def _coerce_count(value: Any) -> int | None:
1878
+ if isinstance(value, bool) or value is None:
1879
+ return None
1880
+ if isinstance(value, int):
1881
+ return value
1882
+ if isinstance(value, float):
1883
+ return int(value)
1884
+ if isinstance(value, str):
1885
+ text = value.strip()
1886
+ if not text:
1887
+ return None
1888
+ try:
1889
+ return int(text)
1890
+ except ValueError:
1891
+ return None
1892
+ return None
1893
+
1894
+
1895
+ def _looks_like_full_solution_spec(payload: dict[str, Any]) -> bool:
1896
+ if not isinstance(payload, dict):
1897
+ return False
1898
+ if not isinstance(payload.get("entities"), list):
1899
+ return False
1900
+ return any(
1901
+ key in payload
1902
+ for key in (
1903
+ "portal",
1904
+ "navigation",
1905
+ "requirements",
1906
+ "success_metrics",
1907
+ "roles",
1908
+ "package",
1909
+ "business_context",
1910
+ )
1911
+ )
1912
+
1913
+
1914
+ def _project_stage_payload(stage_name: str, solution_spec: dict[str, Any]) -> dict[str, Any]:
1915
+ if stage_name == "app":
1916
+ entities = deepcopy(solution_spec.get("entities", []))
1917
+ for entity in entities:
1918
+ if not isinstance(entity, dict):
1919
+ continue
1920
+ entity.pop("workflow", None)
1921
+ entity.pop("lifecycle_stages", None)
1922
+ entity.pop("views", None)
1923
+ entity.pop("charts", None)
1924
+ projected = {
1925
+ "solution_name": solution_spec.get("solution_name"),
1926
+ "summary": solution_spec.get("summary"),
1927
+ "business_context": deepcopy(solution_spec.get("business_context", {})),
1928
+ "package": deepcopy(solution_spec.get("package", {})),
1929
+ "entities": entities,
1930
+ "roles": deepcopy(solution_spec.get("roles", [])),
1931
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
1932
+ "preferences": deepcopy(solution_spec.get("preferences", {})),
1933
+ "assumptions": deepcopy(solution_spec.get("assumptions", [])),
1934
+ "constraints": deepcopy(solution_spec.get("constraints", [])),
1935
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
1936
+ }
1937
+ return {key: value for key, value in projected.items() if value is not None}
1938
+
1939
+ if stage_name == "flow":
1940
+ projected_entities: list[dict[str, Any]] = []
1941
+ for entity in solution_spec.get("entities", []):
1942
+ if not isinstance(entity, dict):
1943
+ continue
1944
+ flow_entity = {"entity_id": entity.get("entity_id")}
1945
+ for key in ("workflow", "lifecycle_stages", "status_field_id", "owner_field_id", "start_field_id", "end_field_id", "metadata"):
1946
+ if key in entity:
1947
+ flow_entity[key] = deepcopy(entity[key])
1948
+ projected_entities.append(flow_entity)
1949
+ return {
1950
+ "solution_name": solution_spec.get("solution_name"),
1951
+ "entities": projected_entities,
1952
+ "roles": deepcopy(solution_spec.get("roles", [])),
1953
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
1954
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
1955
+ }
1956
+
1957
+ if stage_name == "app_flow":
1958
+ entities = deepcopy(solution_spec.get("entities", []))
1959
+ for entity in entities:
1960
+ if not isinstance(entity, dict):
1961
+ continue
1962
+ entity.pop("views", None)
1963
+ entity.pop("charts", None)
1964
+ projected = {
1965
+ "solution_name": solution_spec.get("solution_name"),
1966
+ "summary": solution_spec.get("summary"),
1967
+ "business_context": deepcopy(solution_spec.get("business_context", {})),
1968
+ "package": deepcopy(solution_spec.get("package", {})),
1969
+ "entities": entities,
1970
+ "roles": deepcopy(solution_spec.get("roles", [])),
1971
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
1972
+ "preferences": deepcopy(solution_spec.get("preferences", {})),
1973
+ "assumptions": deepcopy(solution_spec.get("assumptions", [])),
1974
+ "constraints": deepcopy(solution_spec.get("constraints", [])),
1975
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
1976
+ }
1977
+ return {key: value for key, value in projected.items() if value is not None}
1978
+
1979
+ if stage_name == "views":
1980
+ return {
1981
+ "solution_name": solution_spec.get("solution_name"),
1982
+ "entities": [
1983
+ {
1984
+ "entity_id": entity.get("entity_id"),
1985
+ "views": deepcopy(entity.get("views", [])),
1986
+ }
1987
+ for entity in solution_spec.get("entities", [])
1988
+ if isinstance(entity, dict) and entity.get("views")
1989
+ ],
1990
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
1991
+ }
1992
+
1993
+ if stage_name == "analytics_portal":
1994
+ return {
1995
+ "solution_name": solution_spec.get("solution_name"),
1996
+ "entities": [
1997
+ {
1998
+ "entity_id": entity.get("entity_id"),
1999
+ "charts": deepcopy(entity.get("charts", [])),
2000
+ }
2001
+ for entity in solution_spec.get("entities", [])
2002
+ if isinstance(entity, dict) and entity.get("charts")
2003
+ ],
2004
+ "portal": deepcopy(solution_spec.get("portal", {})),
2005
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
2006
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
2007
+ }
2008
+
2009
+ if stage_name == "navigation":
2010
+ return {
2011
+ "solution_name": solution_spec.get("solution_name"),
2012
+ "navigation": deepcopy(solution_spec.get("navigation", {})),
2013
+ "publish_policy": deepcopy(solution_spec.get("publish_policy", {})),
2014
+ "metadata": deepcopy(solution_spec.get("metadata", {})),
2015
+ }
2016
+ raise ValueError(f"unsupported stage '{stage_name}'")
2017
+
2018
+
2019
+ def _split_solution_spec_into_stage_payloads(solution_spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
2020
+ return {stage_name: _project_stage_payload(stage_name, solution_spec) for stage_name in STAGE_ORDER}
2021
+
2022
+
2023
+ def _stage_payload_is_empty(stage_name: str, stage_payload: dict[str, Any]) -> bool:
2024
+ if stage_name == "app_flow":
2025
+ return not bool(stage_payload.get("entities"))
2026
+ if stage_name == "views":
2027
+ return not any(
2028
+ isinstance(entity, dict) and entity.get("views")
2029
+ for entity in stage_payload.get("entities", [])
2030
+ )
2031
+ if stage_name == "analytics_portal":
2032
+ has_charts = any(
2033
+ isinstance(entity, dict) and entity.get("charts")
2034
+ for entity in stage_payload.get("entities", [])
2035
+ )
2036
+ portal_payload = stage_payload.get("portal", {}) if isinstance(stage_payload.get("portal"), dict) else {}
2037
+ has_portal = bool(portal_payload.get("sections")) and portal_payload.get("enabled", True) is not False
2038
+ return not has_charts and not has_portal
2039
+ if stage_name == "navigation":
2040
+ navigation_payload = stage_payload.get("navigation", {}) if isinstance(stage_payload.get("navigation"), dict) else {}
2041
+ return not (navigation_payload.get("enabled", True) and navigation_payload.get("items"))
2042
+ raise ValueError(f"unsupported stage '{stage_name}'")
2043
+
2044
+
2045
+ def _stage_skip_reason(stage_name: str) -> str:
2046
+ reasons = {
2047
+ "app_flow": "No app flow entities were provided for this build stage.",
2048
+ "views": "No view definitions were provided, so the views stage was skipped.",
2049
+ "analytics_portal": "No charts or enabled portal sections were provided, so the analytics stage was skipped.",
2050
+ "navigation": "No enabled navigation items were provided, so the navigation stage was skipped.",
2051
+ }
2052
+ return reasons.get(stage_name, "Stage skipped because no applicable payload was provided.")
2053
+
2054
+
2055
+ def _append_verification_error(verification: dict[str, Any], scope: str, exc: Exception) -> None:
2056
+ errors = verification.setdefault("errors", [])
2057
+ if isinstance(errors, list):
2058
+ errors.append(
2059
+ {
2060
+ "scope": scope,
2061
+ "message": str(exc),
2062
+ }
2063
+ )
2064
+
2065
+
2066
+ PREFERRED_RECORD_TITLE_LABELS = (
2067
+ "名称",
2068
+ "客户名称",
2069
+ "医疗机构名称",
2070
+ "联系人姓名",
2071
+ "商机名称",
2072
+ "销售机会名称",
2073
+ "合同名称",
2074
+ "采购合同名称",
2075
+ "拜访主题",
2076
+ "主题",
2077
+ "项目名称",
2078
+ "任务名称",
2079
+ )
2080
+
2081
+
2082
+ def _readback_record_page(
2083
+ record_tools: RecordTools,
2084
+ profile: str,
2085
+ app_key: str,
2086
+ *,
2087
+ entity_spec: dict[str, Any] | None = None,
2088
+ field_meta: dict[str, Any] | None = None,
2089
+ ) -> dict[str, Any]:
2090
+ select_columns = _readback_select_columns(entity_spec=entity_spec, field_meta=field_meta)
2091
+ fallback: dict[str, Any] = {}
2092
+ for list_type in (8, 9):
2093
+ response = record_tools.record_query(
2094
+ profile=profile,
2095
+ query_mode="list",
2096
+ app_key=app_key,
2097
+ apply_id=None,
2098
+ page_num=1,
2099
+ page_size=20,
2100
+ requested_pages=1,
2101
+ scan_max_pages=1,
2102
+ query_key=None,
2103
+ filters=[],
2104
+ sorts=[],
2105
+ max_rows=20,
2106
+ max_columns=max(1, len(select_columns)),
2107
+ select_columns=select_columns,
2108
+ amount_column=None,
2109
+ time_range={},
2110
+ stat_policy={},
2111
+ strict_full=False,
2112
+ output_profile="compact",
2113
+ list_type=list_type,
2114
+ view_key=None,
2115
+ view_name=None,
2116
+ )
2117
+ data = response.get("data") if isinstance(response, dict) else None
2118
+ list_payload = data.get("list") if isinstance(data, dict) and isinstance(data.get("list"), dict) else {}
2119
+ pagination = list_payload.get("pagination") if isinstance(list_payload.get("pagination"), dict) else {}
2120
+ rows = list_payload.get("rows") if isinstance(list_payload.get("rows"), list) else []
2121
+ merged_page = {
2122
+ "list": rows,
2123
+ "backend_reported_total": _coerce_count(pagination.get("backend_reported_total")),
2124
+ "returned_rows": _coerce_count(pagination.get("returned_items")) or len(rows),
2125
+ "effective_count": _coerce_count(pagination.get("result_amount")),
2126
+ "list_type": list_type,
2127
+ "list_type_label": get_record_list_type_label(list_type),
2128
+ "page_num": _coerce_count(pagination.get("page_num")) or 1,
2129
+ "page_size": _coerce_count(pagination.get("page_size")) or 20,
2130
+ }
2131
+ if not fallback and isinstance(merged_page, dict):
2132
+ fallback = merged_page
2133
+ if rows:
2134
+ return merged_page
2135
+ return fallback
2136
+
2137
+
2138
+ def _extract_record_titles(
2139
+ rows: list[Any],
2140
+ *,
2141
+ entity_spec: dict[str, Any] | None = None,
2142
+ field_meta: dict[str, Any] | None = None,
2143
+ ) -> list[str]:
2144
+ preferred_titles = set(PREFERRED_RECORD_TITLE_LABELS)
2145
+ title_labels, title_que_ids = _title_field_hints(entity_spec=entity_spec, field_meta=field_meta)
2146
+ titles: list[str] = []
2147
+ for row in rows:
2148
+ if not isinstance(row, dict):
2149
+ continue
2150
+ answers = row.get("answers")
2151
+ selected: str | None = None
2152
+ fallback: str | None = None
2153
+ if isinstance(answers, list):
2154
+ for answer in answers:
2155
+ if not isinstance(answer, dict):
2156
+ continue
2157
+ normalized_values = _extract_answer_values(answer)
2158
+ if not normalized_values:
2159
+ continue
2160
+ candidate = " / ".join(normalized_values)
2161
+ answer_title = answer.get("queTitle") or answer.get("title")
2162
+ answer_que_id = _coerce_count(answer.get("queId"))
2163
+ if answer_que_id in title_que_ids or answer_title in title_labels:
2164
+ selected = candidate
2165
+ break
2166
+ if fallback is None and not _looks_like_secondary_answer(answer_title, candidate):
2167
+ fallback = candidate
2168
+ if answer_title in preferred_titles:
2169
+ selected = candidate
2170
+ break
2171
+ title = selected or fallback
2172
+ if title is None:
2173
+ for label in [*sorted(title_labels), *[item for item in PREFERRED_RECORD_TITLE_LABELS if item not in title_labels]]:
2174
+ candidate = _normalize_row_title(row.get(label))
2175
+ if candidate and not _looks_like_secondary_answer(label, candidate):
2176
+ title = candidate
2177
+ break
2178
+ if title is None:
2179
+ for key, value in row.items():
2180
+ if key in {"apply_id", "id"}:
2181
+ continue
2182
+ candidate = _normalize_row_title(value)
2183
+ if candidate and not _looks_like_secondary_answer(key, candidate):
2184
+ title = candidate
2185
+ break
2186
+ if title is None:
2187
+ for key in ("title", "name", "recordName", "applyName"):
2188
+ value = row.get(key)
2189
+ candidate = _normalize_row_title(value)
2190
+ if candidate:
2191
+ title = candidate
2192
+ break
2193
+ if title:
2194
+ titles.append(title)
2195
+ return titles[:5]
2196
+
2197
+
2198
+ def _readback_select_columns(
2199
+ *,
2200
+ entity_spec: dict[str, Any] | None,
2201
+ field_meta: dict[str, Any] | None,
2202
+ ) -> list[str]:
2203
+ title_labels, _ = _title_field_hints(entity_spec=entity_spec, field_meta=field_meta)
2204
+ by_label = field_meta.get("by_label", {}) if isinstance(field_meta, dict) and isinstance(field_meta.get("by_label"), dict) else {}
2205
+ selected: list[str] = []
2206
+
2207
+ def append(label: Any) -> None:
2208
+ if not isinstance(label, str) or not label or label in selected:
2209
+ return
2210
+ if by_label and label not in by_label:
2211
+ return
2212
+ selected.append(label)
2213
+
2214
+ for label in sorted(title_labels):
2215
+ append(label)
2216
+ for label in PREFERRED_RECORD_TITLE_LABELS:
2217
+ append(label)
2218
+ if isinstance(by_label, dict):
2219
+ for label in by_label:
2220
+ append(label)
2221
+ if len(selected) >= 5:
2222
+ break
2223
+ if not selected and isinstance(entity_spec, dict):
2224
+ for field in entity_spec.get("fields", []):
2225
+ if not isinstance(field, dict):
2226
+ continue
2227
+ append(field.get("label"))
2228
+ if len(selected) >= 5:
2229
+ break
2230
+ return selected[:5] or ["名称"]
2231
+
2232
+
2233
+ def _title_field_hints(
2234
+ *,
2235
+ entity_spec: dict[str, Any] | None,
2236
+ field_meta: dict[str, Any] | None,
2237
+ ) -> tuple[set[str], set[int]]:
2238
+ title_labels: set[str] = set()
2239
+ title_que_ids: set[int] = set()
2240
+ if not isinstance(entity_spec, dict):
2241
+ return title_labels, title_que_ids
2242
+ title_field_candidates = {str(entity_spec.get("title_field_id") or "title")}
2243
+ for field in entity_spec.get("fields", []):
2244
+ if not isinstance(field, dict):
2245
+ continue
2246
+ field_id = field.get("field_id")
2247
+ field_label = field.get("label")
2248
+ field_config = field.get("config", {}) if isinstance(field.get("config"), dict) else {}
2249
+ if field_id in title_field_candidates or field_config.get("role") == "title":
2250
+ if isinstance(field_label, str) and field_label:
2251
+ title_labels.add(field_label)
2252
+ if isinstance(field_id, str):
2253
+ title_field_candidates.add(field_id)
2254
+ if isinstance(field_meta, dict):
2255
+ by_field_id = field_meta.get("by_field_id", {})
2256
+ if isinstance(by_field_id, dict):
2257
+ for field_id in title_field_candidates:
2258
+ que_id = _coerce_count(by_field_id.get(field_id))
2259
+ if que_id is not None:
2260
+ title_que_ids.add(que_id)
2261
+ return title_labels, title_que_ids
2262
+
2263
+
2264
+ def _extract_answer_values(answer: dict[str, Any]) -> list[str]:
2265
+ values = answer.get("values")
2266
+ normalized_values: list[str] = []
2267
+ if isinstance(values, list):
2268
+ for value in values:
2269
+ if isinstance(value, str) and value:
2270
+ normalized_values.append(value)
2271
+ continue
2272
+ if isinstance(value, dict):
2273
+ for key in ("value", "name", "text", "label"):
2274
+ item = value.get(key)
2275
+ if isinstance(item, str) and item:
2276
+ normalized_values.append(item)
2277
+ break
2278
+ elif isinstance(values, str) and values:
2279
+ normalized_values.append(values)
2280
+ return normalized_values
2281
+
2282
+
2283
+ def _normalize_row_title(value: Any) -> str | None:
2284
+ if isinstance(value, str):
2285
+ text = value.strip()
2286
+ return text or None
2287
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
2288
+ return str(value)
2289
+ if isinstance(value, list):
2290
+ parts = [_normalize_row_title(item) for item in value]
2291
+ normalized = [part for part in parts if isinstance(part, str) and part]
2292
+ return " / ".join(normalized) if normalized else None
2293
+ if isinstance(value, dict):
2294
+ for key in ("value", "name", "text", "label"):
2295
+ candidate = _normalize_row_title(value.get(key))
2296
+ if candidate:
2297
+ return candidate
2298
+ return None
2299
+
2300
+
2301
+ def _looks_like_secondary_answer(answer_title: Any, candidate: str) -> bool:
2302
+ if not isinstance(candidate, str) or not candidate:
2303
+ return True
2304
+ title = str(answer_title or "")
2305
+ secondary_markers = ("状态", "日期", "时间", "负责人", "处理人", "优先级", "金额", "费用", "电话", "类型")
2306
+ if any(marker in title for marker in secondary_markers):
2307
+ return True
2308
+ if len(candidate) >= 10 and candidate[4] == "-" and candidate[7] == "-":
2309
+ return True
2310
+ return False
2311
+
2312
+
2313
+ def _count_portal_components(payload: dict[str, Any]) -> int | None:
2314
+ if not isinstance(payload, dict):
2315
+ return None
2316
+ for key in ("components", "sections", "dashItemList"):
2317
+ items = payload.get(key)
2318
+ if isinstance(items, list):
2319
+ return len(items)
2320
+ return None
2321
+
2322
+
2323
+ def _extract_navigation_items(items: list[Any]) -> list[dict[str, Any]]:
2324
+ extracted: list[dict[str, Any]] = []
2325
+ for item in items:
2326
+ if not isinstance(item, dict):
2327
+ continue
2328
+ result = item.get("result") if isinstance(item.get("result"), dict) else {}
2329
+ result_payload = result.get("result") if isinstance(result.get("result"), dict) else {}
2330
+ summarized = {
2331
+ "item_id": item.get("item_id"),
2332
+ "navigation_item_id": result_payload.get("navigationItemId") or result_payload.get("id"),
2333
+ "title": result_payload.get("navigationItemName") or result_payload.get("title"),
2334
+ }
2335
+ extracted.append({key: value for key, value in summarized.items() if value is not None})
2336
+ return extracted
2337
+
2338
+
2339
+ def _resolve_target_package_tag_id(target: dict[str, Any]) -> int | None:
2340
+ if not target:
2341
+ return None
2342
+ package_tag_id = target.get("package_tag_id")
2343
+ if package_tag_id is None and isinstance(target.get("package"), dict):
2344
+ package_tag_id = target["package"].get("tag_id")
2345
+ if package_tag_id is None:
2346
+ return None
2347
+ if not isinstance(package_tag_id, int) or package_tag_id <= 0:
2348
+ raise ValueError("target.package_tag_id must be a positive integer")
2349
+ return package_tag_id
2350
+
2351
+
2352
+ def _bind_existing_package_target(compiled: CompiledSolution, package_tag_id: int) -> CompiledSolution:
2353
+ compiled.package_payload = None
2354
+ compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False)
2355
+ for entity in compiled.entities:
2356
+ entity.app_create_payload["tagIds"] = [package_tag_id]
2357
+ return compiled
2358
+
2359
+
2360
+ def _merge_stage_manifest(*, stage_name: str, manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
2361
+ merged = deepcopy(manifest or default_manifest())
2362
+ if stage_name == "app_flow":
2363
+ return _merge_app_flow_manifest(merged, stage_payload)
2364
+ if stage_name == "views":
2365
+ return _merge_views_manifest(merged, stage_payload)
2366
+ if stage_name == "analytics_portal":
2367
+ return _merge_analytics_portal_manifest(merged, stage_payload)
2368
+ if stage_name == "navigation":
2369
+ return _merge_navigation_manifest(merged, stage_payload)
2370
+ raise ValueError(f"unsupported stage '{stage_name}'")
2371
+
2372
+
2373
+ def _merge_app_flow_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
2374
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
2375
+ merged_entities: list[dict[str, Any]] = []
2376
+ staged_entity_ids: set[str] = set()
2377
+ for entity in stage_payload.get("entities", []):
2378
+ existing = entity_map.get(entity["entity_id"], {})
2379
+ next_entity = deepcopy(existing)
2380
+ next_entity.update(deepcopy(entity))
2381
+ next_entity["views"] = deepcopy(existing.get("views", []))
2382
+ next_entity["charts"] = deepcopy(existing.get("charts", []))
2383
+ merged_entities.append(next_entity)
2384
+ staged_entity_ids.add(entity["entity_id"])
2385
+ for entity_id, entity in entity_map.items():
2386
+ if entity_id not in staged_entity_ids:
2387
+ merged_entities.append(deepcopy(entity))
2388
+ manifest["solution_name"] = stage_payload.get("solution_name", manifest.get("solution_name"))
2389
+ manifest["summary"] = stage_payload.get("summary", manifest.get("summary"))
2390
+ manifest["business_context"] = deepcopy(stage_payload.get("business_context", manifest.get("business_context", {})))
2391
+ manifest["package"] = deepcopy(_merge_dict(manifest.get("package", {}), stage_payload.get("package", {})))
2392
+ manifest["entities"] = merged_entities
2393
+ if "roles" in stage_payload:
2394
+ manifest["roles"] = deepcopy(stage_payload["roles"])
2395
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload.get("publish_policy", {})))
2396
+ manifest["preferences"] = deepcopy(_merge_dict(manifest.get("preferences", {}), stage_payload.get("preferences", {})))
2397
+ if "assumptions" in stage_payload:
2398
+ manifest["assumptions"] = deepcopy(stage_payload["assumptions"])
2399
+ if "constraints" in stage_payload:
2400
+ manifest["constraints"] = deepcopy(stage_payload["constraints"])
2401
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
2402
+ return manifest
2403
+
2404
+
2405
+ def _merge_views_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
2406
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
2407
+ for entity_patch in stage_payload.get("entities", []):
2408
+ entity_id = entity_patch["entity_id"]
2409
+ if entity_id not in entity_map:
2410
+ raise ValueError(f"views stage references unknown entity '{entity_id}'")
2411
+ entity_map[entity_id]["views"] = deepcopy(entity_patch.get("views", []))
2412
+ manifest["entities"] = list(entity_map.values())
2413
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
2414
+ return manifest
2415
+
2416
+
2417
+ def _merge_analytics_portal_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
2418
+ entity_map = {entity["entity_id"]: deepcopy(entity) for entity in manifest.get("entities", [])}
2419
+ for entity_patch in stage_payload.get("entities", []):
2420
+ entity_id = entity_patch["entity_id"]
2421
+ if entity_id not in entity_map:
2422
+ raise ValueError(f"analytics stage references unknown entity '{entity_id}'")
2423
+ entity_map[entity_id]["charts"] = deepcopy(entity_patch.get("charts", []))
2424
+ manifest["entities"] = list(entity_map.values())
2425
+ if "portal" in stage_payload:
2426
+ portal_payload = deepcopy(stage_payload["portal"])
2427
+ if portal_payload.get("sections"):
2428
+ manifest["portal"] = deepcopy(_merge_dict(manifest.get("portal", {}), portal_payload))
2429
+ manifest["portal"]["enabled"] = portal_payload.get("enabled", True)
2430
+ manifest.setdefault("preferences", {})
2431
+ manifest["preferences"]["create_portal"] = bool(portal_payload.get("enabled", True))
2432
+ elif portal_payload.get("enabled") is False:
2433
+ manifest["portal"] = deepcopy(_merge_dict(manifest.get("portal", {}), portal_payload))
2434
+ manifest.setdefault("preferences", {})
2435
+ manifest["preferences"]["create_portal"] = False
2436
+ if "publish_policy" in stage_payload:
2437
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload["publish_policy"]))
2438
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
2439
+ return manifest
2440
+
2441
+
2442
+ def _merge_navigation_manifest(manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
2443
+ navigation_payload = deepcopy(stage_payload.get("navigation", {}))
2444
+ manifest["navigation"] = deepcopy(_merge_dict(manifest.get("navigation", {}), navigation_payload))
2445
+ if navigation_payload.get("items"):
2446
+ manifest["navigation"]["enabled"] = navigation_payload.get("enabled", True)
2447
+ manifest.setdefault("preferences", {})
2448
+ manifest["preferences"]["create_navigation"] = bool(navigation_payload.get("enabled", True))
2449
+ if "publish_policy" in stage_payload:
2450
+ manifest["publish_policy"] = deepcopy(_merge_dict(manifest.get("publish_policy", {}), stage_payload["publish_policy"]))
2451
+ manifest["metadata"] = deepcopy(_merge_dict(manifest.get("metadata", {}), stage_payload.get("metadata", {})))
2452
+ return manifest
2453
+
2454
+
2455
+ def _filter_compiled_solution(
2456
+ compiled: CompiledSolution,
2457
+ *,
2458
+ stage_name: str,
2459
+ stage_payload: dict[str, Any],
2460
+ stage_variant: str | None = None,
2461
+ ) -> CompiledSolution:
2462
+ included_step_names = _stage_step_names(
2463
+ compiled.execution_plan,
2464
+ stage_name=stage_name,
2465
+ stage_payload=stage_payload,
2466
+ stage_variant=stage_variant,
2467
+ )
2468
+ allowed = set(included_step_names)
2469
+ filtered_steps = [
2470
+ ExecutionStep(
2471
+ step_name=step.step_name,
2472
+ resource_type=step.resource_type,
2473
+ resource_ref=step.resource_ref,
2474
+ description=step.description,
2475
+ depends_on=[dependency for dependency in step.depends_on if dependency in allowed],
2476
+ )
2477
+ for step in compiled.execution_plan.steps
2478
+ if step.step_name in allowed
2479
+ ]
2480
+ filtered = deepcopy(compiled)
2481
+ filtered.execution_plan = ExecutionPlan(steps=filtered_steps)
2482
+ return filtered
2483
+
2484
+
2485
+ def _stage_step_names(
2486
+ plan: ExecutionPlan,
2487
+ *,
2488
+ stage_name: str,
2489
+ stage_payload: dict[str, Any],
2490
+ stage_variant: str | None = None,
2491
+ ) -> list[str]:
2492
+ variant = stage_variant or stage_name
2493
+ if variant == "navigation":
2494
+ return [step.step_name for step in plan.steps if step.step_name in {"navigation.create", "publish.navigation"}]
2495
+
2496
+ entity_ids = {
2497
+ entity["entity_id"]
2498
+ for entity in stage_payload.get("entities", [])
2499
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
2500
+ }
2501
+ included: list[str] = []
2502
+ for step in plan.steps:
2503
+ if variant == "app":
2504
+ if (
2505
+ step.step_name == "package.create"
2506
+ or step.step_name.startswith("role.create.")
2507
+ or any(
2508
+ step.step_name.startswith(prefix)
2509
+ for prefix in (
2510
+ "app.create.",
2511
+ "form.base.",
2512
+ "form.relations.",
2513
+ "publish.form.",
2514
+ "publish.app.",
2515
+ "seed_data.",
2516
+ )
2517
+ )
2518
+ ):
2519
+ 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":
2520
+ continue
2521
+ included.append(step.step_name)
2522
+ continue
2523
+ if variant == "flow":
2524
+ if (
2525
+ step.step_name.startswith("role.create.")
2526
+ or step.step_name.startswith("workflow.")
2527
+ or step.step_name.startswith("publish.workflow.")
2528
+ or step.step_name.startswith("publish.app.")
2529
+ ):
2530
+ if "." in step.step_name and step.resource_ref not in entity_ids and not step.step_name.startswith("role.create."):
2531
+ continue
2532
+ included.append(step.step_name)
2533
+ continue
2534
+ if variant == "app_flow":
2535
+ if (
2536
+ step.step_name == "package.create"
2537
+ or step.step_name.startswith("role.create.")
2538
+ or any(
2539
+ step.step_name.startswith(prefix)
2540
+ for prefix in (
2541
+ "app.create.",
2542
+ "form.base.",
2543
+ "form.relations.",
2544
+ "workflow.",
2545
+ "publish.form.",
2546
+ "publish.workflow.",
2547
+ "publish.app.",
2548
+ "seed_data.",
2549
+ )
2550
+ )
2551
+ ):
2552
+ 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":
2553
+ continue
2554
+ included.append(step.step_name)
2555
+ continue
2556
+ if variant == "views":
2557
+ if step.resource_ref in entity_ids and (step.step_name.startswith("views.") or step.step_name.startswith("publish.app.")):
2558
+ included.append(step.step_name)
2559
+ continue
2560
+ if variant == "analytics_portal":
2561
+ if step.resource_ref in entity_ids and (step.step_name.startswith("charts.") or step.step_name.startswith("publish.app.")):
2562
+ included.append(step.step_name)
2563
+ continue
2564
+ if step.step_name in {"portal.create", "publish.portal"}:
2565
+ included.append(step.step_name)
2566
+ continue
2567
+ return included
2568
+
2569
+
2570
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
2571
+ merged = deepcopy(base)
2572
+ for key, value in patch.items():
2573
+ merged[key] = deepcopy(value)
2574
+ return merged
2575
+
2576
+
2577
+ def _stage_run_id(build_id: str, stage_name: str) -> str:
2578
+ return f"{build_id}--{stage_name}"
2579
+
2580
+
2581
+ def _build_status_for_stage(stage_name: str, result_status: str) -> str:
2582
+ if result_status != "success":
2583
+ return result_status
2584
+ return "success" if stage_name == "navigation" else "ready"
2585
+
2586
+
2587
+ def _build_summary(assembly: BuildAssemblyStore) -> dict[str, Any]:
2588
+ summary = assembly.summary()
2589
+ stage_specs = summary.get("stage_specs", {}) if isinstance(summary.get("stage_specs"), dict) else {}
2590
+ stage_history = summary.get("stage_history", []) if isinstance(summary.get("stage_history"), list) else []
2591
+ latest_entries: dict[str, dict[str, Any]] = {}
2592
+ for entry in stage_history:
2593
+ stage = entry.get("stage")
2594
+ if isinstance(stage, str):
2595
+ latest_entries[stage] = entry
2596
+ stage_statuses: dict[str, str] = {}
2597
+ for stage in STAGE_ORDER:
2598
+ latest = latest_entries.get(stage)
2599
+ if latest and latest.get("status"):
2600
+ stage_statuses[stage] = str(latest["status"])
2601
+ continue
2602
+ stage_statuses[stage] = "draft" if stage_specs.get(stage) else "pending"
2603
+ 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 {})]
2604
+ completed_stages = [stage for stage, status in stage_statuses.items() if status in {"success", "ready", "skipped"}]
2605
+ next_stage = next((stage for stage in STAGE_ORDER if stage_statuses.get(stage) not in {"success", "ready", "skipped"}), None)
2606
+ prerequisites = {
2607
+ stage: _missing_stage_prerequisites(stage_name=stage, assembly=assembly, stage_payload=stage_specs.get(stage, {}) or {})
2608
+ for stage in STAGE_ORDER
2609
+ }
2610
+ return {
2611
+ "build_id": summary.get("build_id"),
2612
+ "status": summary.get("status"),
2613
+ "build_path": summary.get("build_path"),
2614
+ "stage_statuses": stage_statuses,
2615
+ "completed_stages": completed_stages,
2616
+ "ready_stages": ready_stages,
2617
+ "next_recommended_stage": next_stage,
2618
+ "missing_prerequisites": prerequisites,
2619
+ "stage_history": stage_history,
2620
+ }
2621
+
2622
+
2623
+ def _missing_stage_prerequisites(*, stage_name: str, assembly: BuildAssemblyStore, stage_payload: dict[str, Any]) -> list[str]:
2624
+ manifest = assembly.get_manifest()
2625
+ stage_specs = (assembly.data.get("stage_specs", {}) or {}) if isinstance(assembly.data.get("stage_specs"), dict) else {}
2626
+ entity_map = {
2627
+ entity.get("entity_id"): entity
2628
+ for entity in manifest.get("entities", [])
2629
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
2630
+ }
2631
+ missing: list[str] = []
2632
+ if stage_name in {"app_flow", "app"}:
2633
+ return missing
2634
+ if stage_name == "flow":
2635
+ if not entity_map and not stage_specs.get("app_flow"):
2636
+ return ["Run solution_build_app first to define package, apps, and fields before configuring workflow."]
2637
+ for entity in stage_payload.get("entities", []):
2638
+ entity_id = entity.get("entity_id")
2639
+ if entity_id not in entity_map:
2640
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app first.")
2641
+ return list(dict.fromkeys(missing))
2642
+ if not entity_map and not stage_specs.get("app_flow"):
2643
+ return ["Run solution_build_app_flow first to define package, apps, and form/workflow entities."]
2644
+ if stage_name == "views":
2645
+ for entity in stage_payload.get("entities", []):
2646
+ entity_id = entity.get("entity_id")
2647
+ if entity_id not in entity_map:
2648
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app_flow first.")
2649
+ return missing
2650
+ if stage_name == "analytics_portal":
2651
+ for entity in stage_payload.get("entities", []):
2652
+ entity_id = entity.get("entity_id")
2653
+ if entity_id not in entity_map:
2654
+ missing.append(f"Entity '{entity_id}' is not defined yet. Run solution_build_app_flow first.")
2655
+ portal_payload = stage_payload.get("portal", {}) if isinstance(stage_payload.get("portal"), dict) else {}
2656
+ for section in portal_payload.get("sections", []):
2657
+ if section.get("source_type") == "view":
2658
+ entity_id = section.get("entity_id")
2659
+ view_id = section.get("view_id")
2660
+ entity = entity_map.get(entity_id) if isinstance(entity_id, str) else None
2661
+ known_view_ids = {
2662
+ view.get("view_id")
2663
+ for view in (entity.get("views", []) if isinstance(entity, dict) else [])
2664
+ if isinstance(view, dict)
2665
+ }
2666
+ if view_id not in known_view_ids:
2667
+ missing.append(
2668
+ f"Portal section '{section.get('section_id')}' references view '{view_id}' on entity '{entity_id}'. Run solution_build_views first."
2669
+ )
2670
+ return list(dict.fromkeys(missing))
2671
+ if stage_name == "navigation":
2672
+ navigation_payload = stage_payload.get("navigation", {}) if isinstance(stage_payload.get("navigation"), dict) else {}
2673
+ package_targets = [
2674
+ item for item in navigation_payload.get("items", [])
2675
+ if isinstance(item, dict) and item.get("target_type") == "package"
2676
+ ]
2677
+ if not package_targets:
2678
+ missing.append("Navigation stage must include a package target item as the single published navigation root.")
2679
+ return list(dict.fromkeys(missing))
2680
+ return missing