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

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