@josephyan/qingflow-app-builder-mcp 0.1.0-beta.13 → 0.1.0-beta.14

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.1.0-beta.13
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.1.0-beta.14
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.1.0-beta.13 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.1.0-beta.14 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.14",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.1.0b13"
7
+ version = "0.1.0b14"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.0b13"
5
+ __version__ = "0.1.0b14"
@@ -229,6 +229,8 @@ def build_builder_server() -> FastMCP:
229
229
  requirement_text: str = "",
230
230
  package_tag_id: int = 0,
231
231
  package_name: str = "",
232
+ app_key: str = "",
233
+ update_only: bool = False,
232
234
  layout_style: str = "auto",
233
235
  publish: bool = True,
234
236
  run_label: str | None = None,
@@ -242,6 +244,8 @@ def build_builder_server() -> FastMCP:
242
244
  requirement_text=requirement_text,
243
245
  package_tag_id=package_tag_id,
244
246
  package_name=package_name,
247
+ app_key=app_key,
248
+ update_only=update_only,
245
249
  layout_style=layout_style,
246
250
  publish=publish,
247
251
  run_label=run_label,
@@ -278,6 +282,9 @@ def build_builder_server() -> FastMCP:
278
282
  mode: str = "plan",
279
283
  build_id: str = "",
280
284
  app_spec: dict | None = None,
285
+ app_key: str = "",
286
+ package_tag_id: int = 0,
287
+ update_only: bool = False,
281
288
  publish: bool = True,
282
289
  run_label: str | None = None,
283
290
  target: dict | None = None,
@@ -288,6 +295,9 @@ def build_builder_server() -> FastMCP:
288
295
  mode=mode,
289
296
  build_id=build_id,
290
297
  app_spec=app_spec or {},
298
+ app_key=app_key,
299
+ package_tag_id=package_tag_id,
300
+ update_only=update_only,
291
301
  publish=publish,
292
302
  run_label=run_label,
293
303
  target=target or {},
@@ -128,8 +128,13 @@ def compile_role(role: RoleSpec) -> CompiledRole:
128
128
  return CompiledRole(role_id=role.role_id, name=role.name, payload=payload)
129
129
 
130
130
 
131
- def build_execution_plan(spec: SolutionSpec, include_package: bool) -> ExecutionPlan:
131
+ def build_execution_plan(
132
+ spec: SolutionSpec,
133
+ include_package: bool,
134
+ attach_package: bool | None = None,
135
+ ) -> ExecutionPlan:
132
136
  steps: list[ExecutionStep] = []
137
+ attach_package = include_package if attach_package is None else attach_package
133
138
  if include_package:
134
139
  steps.append(ExecutionStep("package.create", "package", spec.package.name or spec.solution_name, "创建应用包"))
135
140
  for role in spec.roles:
@@ -139,7 +144,19 @@ def build_execution_plan(spec: SolutionSpec, include_package: bool) -> Execution
139
144
  entity_ref = entity.entity_id
140
145
  dependencies = ["package.create"] if include_package else []
141
146
  steps.append(ExecutionStep(f"app.create.{entity_ref}", "app", entity_ref, f"创建应用 {entity.display_name}", dependencies.copy()))
142
- steps.append(ExecutionStep(f"form.base.{entity_ref}", "form", entity_ref, f"写入基础表单 {entity.display_name}", [f"app.create.{entity_ref}"]))
147
+ last_form_step = f"app.create.{entity_ref}"
148
+ if attach_package:
149
+ steps.append(
150
+ ExecutionStep(
151
+ f"package.attach.{entity_ref}",
152
+ "package_attach",
153
+ entity_ref,
154
+ f"将应用加入应用包 {entity.display_name}",
155
+ [f"app.create.{entity_ref}"],
156
+ )
157
+ )
158
+ last_form_step = f"package.attach.{entity_ref}"
159
+ steps.append(ExecutionStep(f"form.base.{entity_ref}", "form", entity_ref, f"写入基础表单 {entity.display_name}", [last_form_step]))
143
160
  last_form_step = f"form.base.{entity_ref}"
144
161
  if any(field.type.value == "relation" for field in entity.fields):
145
162
  steps.append(ExecutionStep(f"form.relations.{entity_ref}", "form", entity_ref, f"回填关联字段 {entity.display_name}", [f"form.base.{entity_ref}"]))
@@ -24,6 +24,7 @@ from .spec_models import FieldType
24
24
  NAVIGATION_PLUGIN_ID = 45
25
25
  GRID_COMPONENT_PLUGIN_ID = 34
26
26
  BI_CHART_COMPONENT_TYPE = 9
27
+ PACKAGE_ITEM_TYPE_FORM = 1
27
28
 
28
29
 
29
30
  class SolutionExecutor:
@@ -90,6 +91,17 @@ class SolutionExecutor:
90
91
  context["resource"] = "package"
91
92
  context["package_name"] = compiled.normalized_spec.package.name or compiled.normalized_spec.solution_name
92
93
  return context
94
+ if step_name.startswith("package.attach."):
95
+ entity = self._entity_from_step(compiled, step_name)
96
+ context.update(
97
+ {
98
+ "resource": "package_attach",
99
+ "entity_id": entity.entity_id,
100
+ "display_name": entity.display_name,
101
+ "package_tag_id": self._current_store.get_artifact("package", "tag_id") if hasattr(self, "_current_store") else None,
102
+ }
103
+ )
104
+ return context
93
105
  if step_name == "portal.create":
94
106
  context["resource"] = "portal"
95
107
  context["portal_name"] = compiled.normalized_spec.portal.name
@@ -125,6 +137,10 @@ class SolutionExecutor:
125
137
  if step_name == "package.create":
126
138
  self._create_package(profile, compiled, store)
127
139
  return
140
+ if step_name.startswith("package.attach."):
141
+ entity = self._entity_from_step(compiled, step_name)
142
+ self._attach_app_to_package(profile, entity, store)
143
+ return
128
144
  if step_name.startswith("role.create."):
129
145
  role = self._role_from_step(compiled, step_name)
130
146
  self._create_role(profile, role, store)
@@ -261,6 +277,10 @@ class SolutionExecutor:
261
277
  existing_artifact = store.get_artifact("apps", entity.entity_id, {}) or {}
262
278
  existing_app_key = existing_artifact.get("app_key")
263
279
  if isinstance(existing_app_key, str) and existing_app_key:
280
+ next_artifact = deepcopy(existing_artifact)
281
+ next_artifact["reused"] = True
282
+ next_artifact.setdefault("target_mode", "update")
283
+ store.set_artifact("apps", entity.entity_id, next_artifact)
264
284
  return
265
285
  payload = self._resolve_app_payload(entity.app_create_payload, store)
266
286
  result = self.app_tools.app_create(profile=profile, payload=payload)
@@ -283,6 +303,64 @@ class SolutionExecutor:
283
303
  store.set_artifact("apps", entity.entity_id, app_artifact)
284
304
  raise
285
305
 
306
+ def _attach_app_to_package(self, profile: str, entity: CompiledEntity, store: RunArtifactStore) -> None:
307
+ tag_id = store.get_artifact("package", "tag_id")
308
+ if not isinstance(tag_id, int) or tag_id <= 0:
309
+ return
310
+ app_artifact = store.get_artifact("apps", entity.entity_id, {}) or {}
311
+ app_key = app_artifact.get("app_key")
312
+ if not isinstance(app_key, str) or not app_key:
313
+ raise ValueError(f"missing app_key for package attach on entity '{entity.entity_id}'")
314
+
315
+ package_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
316
+ package_result = package_detail.get("result") if isinstance(package_detail.get("result"), dict) else {}
317
+ tag_items = [deepcopy(item) for item in package_result.get("tagItems", []) if isinstance(item, dict)]
318
+ if any(_package_item_app_key(item) == app_key for item in tag_items):
319
+ self._record_package_attachment(store, entity.entity_id, app_artifact, tag_id=tag_id, attached=True, reused=True)
320
+ return
321
+
322
+ item = {
323
+ "itemType": PACKAGE_ITEM_TYPE_FORM,
324
+ "appKey": app_key,
325
+ "title": entity.display_name,
326
+ "iconUrl": entity.app_create_payload.get("appIcon"),
327
+ }
328
+ insert_at = _resolve_package_item_insert_index(tag_items, entity.app_create_payload.get("ordinal"))
329
+ updated_items = list(tag_items)
330
+ updated_items.insert(insert_at, item)
331
+ self.package_tools.package_update(profile=profile, tag_id=tag_id, payload={"tagItems": updated_items})
332
+
333
+ verified_detail = self.package_tools.package_get(profile=profile, tag_id=tag_id, include_raw=True)
334
+ verified_result = verified_detail.get("result") if isinstance(verified_detail.get("result"), dict) else {}
335
+ verified_items = [deepcopy(existing) for existing in verified_result.get("tagItems", []) if isinstance(existing, dict)]
336
+ if not any(_package_item_app_key(existing) == app_key for existing in verified_items):
337
+ raise QingflowApiError(
338
+ category="runtime",
339
+ message=f"failed to attach app '{app_key}' to package '{tag_id}'",
340
+ details={"tag_id": tag_id, "app_key": app_key},
341
+ )
342
+ store.set_artifact("package", "result", verified_detail)
343
+ store.set_artifact("package", "tag_id", tag_id)
344
+ self._record_package_attachment(store, entity.entity_id, app_artifact, tag_id=tag_id, attached=True, reused=False)
345
+
346
+ def _record_package_attachment(
347
+ self,
348
+ store: RunArtifactStore,
349
+ entity_id: str,
350
+ app_artifact: dict[str, Any],
351
+ *,
352
+ tag_id: int,
353
+ attached: bool,
354
+ reused: bool,
355
+ ) -> None:
356
+ next_artifact = deepcopy(app_artifact)
357
+ next_artifact["package_attachment"] = {
358
+ "tag_id": tag_id,
359
+ "attached": attached,
360
+ "reused": reused,
361
+ }
362
+ store.set_artifact("apps", entity_id, next_artifact)
363
+
286
364
  def _update_form_base(self, profile: str, entity: CompiledEntity, store: RunArtifactStore) -> None:
287
365
  app_key = self._get_app_key(store, entity.entity_id)
288
366
  payload = deepcopy(entity.form_base_payload)
@@ -2156,6 +2234,20 @@ def _flatten_questions(value: Any) -> list[dict[str, Any]]:
2156
2234
  return []
2157
2235
 
2158
2236
 
2237
+ def _package_item_app_key(item: dict[str, Any]) -> str | None:
2238
+ item_type = item.get("itemType")
2239
+ if item_type is not None and item_type not in {PACKAGE_ITEM_TYPE_FORM, str(PACKAGE_ITEM_TYPE_FORM)}:
2240
+ return None
2241
+ app_key = item.get("appKey")
2242
+ return app_key if isinstance(app_key, str) and app_key else None
2243
+
2244
+
2245
+ def _resolve_package_item_insert_index(tag_items: list[dict[str, Any]], ordinal: Any) -> int:
2246
+ if not isinstance(ordinal, int) or ordinal <= 0:
2247
+ return len(tag_items)
2248
+ return max(0, min(len(tag_items), ordinal - 1))
2249
+
2250
+
2159
2251
  def _build_viewgraph_questions(schema: dict[str, Any], visible_que_ids: list[int]) -> list[dict[str, Any]]:
2160
2252
  visible_map = {que_id: index for index, que_id in enumerate(visible_que_ids, start=1)}
2161
2253
  questions: list[dict[str, Any]] = []
@@ -351,7 +351,22 @@ def _build_relation_field(*, requirement_text: str, entity_id: str) -> dict[str,
351
351
  "relation field requires an explicit target entity; specify what it should relate to",
352
352
  missing_required_fields=["relation.target_entity_id"],
353
353
  invalid_field_types=["relation"],
354
- details={"requested_field_type": "relation"},
354
+ details={
355
+ "requested_field_type": "relation",
356
+ "suggested_requirement_hint": "例如:关联到客户档案。",
357
+ "suggested_patch": {
358
+ "entities": [
359
+ {
360
+ "entity_id": "target_entity",
361
+ "display_name": "目标应用",
362
+ "kind": EntityKind.master.value,
363
+ "title_field_id": "title",
364
+ "fields": [{"field_id": "title", "label": "标题", "type": FieldType.text.value, "required": True}],
365
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
366
+ }
367
+ ]
368
+ },
369
+ },
355
370
  )
356
371
  target_entity_id = entity_id if target_phrase in {"当前应用", "当前表单", "自身", "本应用"} else _slugify_identifier(target_phrase, prefix="entity")
357
372
  field = _clone_field_blueprint(FIELD_BLUEPRINTS_BY_TYPE[FieldType.relation.value])
@@ -366,7 +381,28 @@ def _build_subtable_field(*, requirement_text: str) -> dict[str, Any]:
366
381
  "subtable field requires explicit child field definitions; specify the row fields first",
367
382
  missing_required_fields=["subtable.subfields"],
368
383
  invalid_field_types=["subtable"],
369
- details={"requested_field_type": "subtable"},
384
+ details={
385
+ "requested_field_type": "subtable",
386
+ "suggested_requirement_hint": "例如:明细表字段包括 子项名称、数量、金额。",
387
+ "suggested_patch": {
388
+ "entities": [
389
+ {
390
+ "fields": [
391
+ {
392
+ "field_id": "detail_items",
393
+ "label": "明细",
394
+ "type": FieldType.subtable.value,
395
+ "subfields": [
396
+ {"field_id": "item_name", "label": "子项名称", "type": FieldType.text.value, "required": True},
397
+ {"field_id": "quantity", "label": "数量", "type": FieldType.number.value},
398
+ {"field_id": "amount", "label": "金额", "type": FieldType.amount.value},
399
+ ],
400
+ }
401
+ ]
402
+ }
403
+ ]
404
+ },
405
+ },
370
406
  )
371
407
  field = _clone_field_blueprint(FIELD_BLUEPRINTS_BY_TYPE[FieldType.subtable.value])
372
408
  field["subfields"] = subfields
@@ -45,6 +45,8 @@ STAGED_BUILD_MODE_ALIASES = {
45
45
  "new": "plan",
46
46
  "start": "plan",
47
47
  "draft": "plan",
48
+ "update": "plan",
49
+ "modify": "plan",
48
50
  "preview": "preflight",
49
51
  "prepare": "preflight",
50
52
  "run": "apply",
@@ -60,7 +62,7 @@ STAGE_TOOL_NAMES = {
60
62
  "analytics_portal": "solution_build_analytics_portal",
61
63
  "navigation": "solution_build_navigation",
62
64
  }
63
- SOLUTION_SCHEMA_STAGES = {"app", "flow", "views", "analytics_portal", "navigation", "app_flow", "all"}
65
+ SOLUTION_SCHEMA_STAGES = {"app", "app_update", "flow", "views", "analytics_portal", "navigation", "app_flow", "all"}
64
66
  SOLUTION_SCHEMA_INTENTS = {"minimal", "full", "demo"}
65
67
  SOLUTION_SCHEMA_STAGE_ALIASES = {
66
68
  "all": "all",
@@ -71,6 +73,12 @@ SOLUTION_SCHEMA_STAGE_ALIASES = {
71
73
  "form": "app",
72
74
  "schema": "app",
73
75
  "app_spec": "app",
76
+ "app_update": "app_update",
77
+ "update_app": "app_update",
78
+ "update": "app_update",
79
+ "更新应用": "app_update",
80
+ "更新表单": "app_update",
81
+ "更新schema": "app_update",
74
82
  "应用": "app",
75
83
  "表单": "app",
76
84
  "flow": "flow",
@@ -163,6 +171,9 @@ class SolutionTools(ToolBase):
163
171
  mode: str = "plan",
164
172
  build_id: str = "",
165
173
  app_spec: dict[str, Any] | None = None,
174
+ app_key: str = "",
175
+ package_tag_id: int = 0,
176
+ update_only: bool = False,
166
177
  publish: bool = True,
167
178
  run_label: str | None = None,
168
179
  target: dict[str, Any] | None = None,
@@ -173,6 +184,9 @@ class SolutionTools(ToolBase):
173
184
  mode=mode,
174
185
  build_id=build_id,
175
186
  app_spec=app_spec or {},
187
+ app_key=app_key,
188
+ package_tag_id=package_tag_id,
189
+ update_only=update_only,
176
190
  publish=publish,
177
191
  run_label=run_label,
178
192
  target=target or {},
@@ -188,6 +202,8 @@ class SolutionTools(ToolBase):
188
202
  requirement_text: str = "",
189
203
  package_tag_id: int = 0,
190
204
  package_name: str = "",
205
+ app_key: str = "",
206
+ update_only: bool = False,
191
207
  layout_style: str = "auto",
192
208
  publish: bool = True,
193
209
  run_label: str | None = None,
@@ -201,6 +217,8 @@ class SolutionTools(ToolBase):
201
217
  requirement_text=requirement_text,
202
218
  package_tag_id=package_tag_id,
203
219
  package_name=package_name,
220
+ app_key=app_key,
221
+ update_only=update_only,
204
222
  layout_style=layout_style,
205
223
  publish=publish,
206
224
  run_label=run_label,
@@ -528,6 +546,9 @@ class SolutionTools(ToolBase):
528
546
  mode: str,
529
547
  build_id: str,
530
548
  app_spec: dict[str, Any],
549
+ app_key: str = "",
550
+ package_tag_id: int = 0,
551
+ update_only: bool = False,
531
552
  publish: bool,
532
553
  run_label: str | None,
533
554
  target: dict[str, Any],
@@ -537,6 +558,12 @@ class SolutionTools(ToolBase):
537
558
  resolved_app_spec = deepcopy(app_spec) if isinstance(app_spec, dict) else {}
538
559
  if not resolved_app_spec and build_id:
539
560
  resolved_app_spec = self._load_generated_app_spec(build_id) or {}
561
+ resolved_target = _merge_app_target(
562
+ target,
563
+ app_key=app_key,
564
+ package_tag_id=package_tag_id,
565
+ update_only=update_only,
566
+ )
540
567
  return self._stage_build(
541
568
  profile=profile,
542
569
  mode=mode,
@@ -546,7 +573,7 @@ class SolutionTools(ToolBase):
546
573
  stage_model=AppBuildSpec,
547
574
  publish=publish,
548
575
  run_label=run_label,
549
- target=target,
576
+ target=resolved_target,
550
577
  repair_patch=repair_patch,
551
578
  public_stage_name="app",
552
579
  tool_name="solution_build_app",
@@ -563,6 +590,8 @@ class SolutionTools(ToolBase):
563
590
  requirement_text: str,
564
591
  package_tag_id: int,
565
592
  package_name: str,
593
+ app_key: str = "",
594
+ update_only: bool = False,
566
595
  layout_style: str,
567
596
  publish: bool,
568
597
  run_label: str | None,
@@ -571,6 +600,12 @@ class SolutionTools(ToolBase):
571
600
  mode = _normalize_staged_build_mode(mode)
572
601
  if mode in {"preflight", "plan"} and not build_id:
573
602
  build_id = _generate_build_id(run_label=run_label, stage_name="app")
603
+ resolved_target = _merge_app_target(
604
+ {},
605
+ app_key=app_key,
606
+ package_tag_id=package_tag_id,
607
+ update_only=update_only,
608
+ )
574
609
  resolved_package = self._resolve_builder_package_reference(
575
610
  profile=profile,
576
611
  package_tag_id=package_tag_id,
@@ -578,11 +613,20 @@ class SolutionTools(ToolBase):
578
613
  )
579
614
  if resolved_package.get("status") == "failed":
580
615
  return resolved_package["response"]
616
+ existing_app = self._resolve_builder_existing_app_reference(
617
+ profile=profile,
618
+ app_key=app_key,
619
+ package_resolution=resolved_package,
620
+ update_only=_target_is_update_only(resolved_target),
621
+ )
622
+ if existing_app.get("status") == "failed":
623
+ return existing_app["response"]
624
+ effective_package_resolution = existing_app.get("package_resolution") if existing_app.get("package_resolution") else resolved_package
581
625
  try:
582
626
  generated = build_app_spec_from_requirements(
583
627
  title=title,
584
628
  requirement_text=requirement_text,
585
- package_name=resolved_package.get("tag_name"),
629
+ package_name=effective_package_resolution.get("tag_name"),
586
630
  layout_style=layout_style,
587
631
  )
588
632
  except RequirementsBuildError as exc:
@@ -591,7 +635,9 @@ class SolutionTools(ToolBase):
591
635
  build_id=build_id,
592
636
  title=title,
593
637
  requirement_text=requirement_text,
594
- package_resolution=resolved_package,
638
+ package_resolution=effective_package_resolution,
639
+ app_key=app_key,
640
+ update_only=_target_is_update_only(resolved_target),
595
641
  layout_style=layout_style,
596
642
  publish=publish,
597
643
  run_label=run_label,
@@ -608,7 +654,9 @@ class SolutionTools(ToolBase):
608
654
  build_id=build_id,
609
655
  title=title,
610
656
  requirement_text=requirement_text,
611
- package_resolution=resolved_package,
657
+ package_resolution=effective_package_resolution,
658
+ app_key=app_key,
659
+ update_only=_target_is_update_only(resolved_target),
612
660
  layout_style=layout_style,
613
661
  publish=publish,
614
662
  run_label=run_label,
@@ -618,7 +666,7 @@ class SolutionTools(ToolBase):
618
666
  self._persist_generated_app_spec(
619
667
  build_id=build_id,
620
668
  generated=generated,
621
- package_resolution=resolved_package,
669
+ package_resolution=effective_package_resolution,
622
670
  title=title,
623
671
  requirement_text=requirement_text,
624
672
  layout_style=layout_style,
@@ -628,16 +676,23 @@ class SolutionTools(ToolBase):
628
676
  mode=mode,
629
677
  build_id=build_id,
630
678
  app_spec=generated.app_spec,
679
+ app_key=app_key,
680
+ package_tag_id=int(effective_package_resolution.get("tag_id") or 0),
681
+ update_only=_target_is_update_only(resolved_target),
631
682
  publish=publish,
632
683
  run_label=run_label,
633
- target=_requirements_target(resolved_package),
684
+ target=_requirements_target(
685
+ effective_package_resolution,
686
+ app_key=app_key if isinstance(app_key, str) else "",
687
+ update_only=_target_is_update_only(resolved_target),
688
+ ),
634
689
  repair_patch={},
635
690
  )
636
691
  return self._requirements_success_response(
637
692
  mode=mode,
638
693
  title=title,
639
694
  requirement_text=requirement_text,
640
- package_resolution=resolved_package,
695
+ package_resolution=effective_package_resolution,
641
696
  layout_style=layout_style,
642
697
  publish=publish,
643
698
  run_label=run_label,
@@ -1101,9 +1156,18 @@ class SolutionTools(ToolBase):
1101
1156
  parsed_manifest = BuildManifest.model_validate(merged_manifest)
1102
1157
  normalized_manifest = normalize_solution_spec(parsed_manifest)
1103
1158
  compiled = compile_solution(normalized_manifest)
1159
+ existing_app_targets = _resolve_target_app_keys(
1160
+ target=target,
1161
+ entity_ids=[entity.entity_id for entity in compiled.entities],
1162
+ )
1163
+ update_only = _target_is_update_only(target) or bool(existing_app_targets)
1164
+ if update_only and not existing_app_targets:
1165
+ raise ValueError("update target requires app_key or target.app_keys")
1104
1166
  package_tag_id = _resolve_target_package_tag_id(target)
1105
1167
  if package_tag_id is not None:
1106
1168
  compiled = _bind_existing_package_target(compiled, package_tag_id)
1169
+ elif update_only:
1170
+ compiled = _bind_existing_app_target(compiled)
1107
1171
  filtered_compiled = _filter_compiled_solution(
1108
1172
  compiled,
1109
1173
  stage_name=stage_name,
@@ -1112,6 +1176,8 @@ class SolutionTools(ToolBase):
1112
1176
  )
1113
1177
  assembly.set_stage_spec(stage_name, resolved_stage_payload)
1114
1178
  assembly.set_manifest(normalized_manifest.model_dump(mode="json"))
1179
+ if existing_app_targets:
1180
+ _seed_existing_app_artifacts(assembly=assembly, existing_app_targets=existing_app_targets)
1115
1181
 
1116
1182
  if mode in {"preflight", "plan"}:
1117
1183
  status = "preflighted" if mode == "preflight" else "planned"
@@ -1170,11 +1236,24 @@ class SolutionTools(ToolBase):
1170
1236
  mode=mode,
1171
1237
  )
1172
1238
  assembly.set_artifacts(result["artifacts"])
1239
+ stage_verification = None
1240
+ effective_status = result["status"]
1241
+ if result["status"] == "success":
1242
+ stage_verification = self._verify_stage_write_result(
1243
+ profile=profile,
1244
+ build_id=build_id,
1245
+ public_stage_name=public_stage_name,
1246
+ artifacts=result["artifacts"],
1247
+ solution_spec=normalized_manifest.model_dump(mode="json"),
1248
+ target=target,
1249
+ )
1250
+ if isinstance(stage_verification, dict) and stage_verification.get("status") == "partial":
1251
+ effective_status = "partial_success"
1173
1252
  failure_signature = None
1174
1253
  stage_failure_count = 0
1175
1254
  next_strategy = None
1176
1255
  suggested_next_call_override = None
1177
- if result["status"] == "failed":
1256
+ if effective_status == "failed":
1178
1257
  failure_signature, stage_failure_count = self._record_failure_signature(
1179
1258
  assembly=assembly,
1180
1259
  stage_name=public_stage_name,
@@ -1191,12 +1270,12 @@ class SolutionTools(ToolBase):
1191
1270
  {
1192
1271
  "stage": stage_name,
1193
1272
  "mode": mode,
1194
- "status": result["status"],
1273
+ "status": effective_status,
1195
1274
  "run_path": result["run_path"],
1196
1275
  "execution_step_count": len(filtered_compiled.execution_plan.steps),
1197
1276
  }
1198
1277
  )
1199
- assembly.mark_status(_build_status_for_stage(stage_name, result["status"]))
1278
+ assembly.mark_status(_build_status_for_stage(stage_name, effective_status))
1200
1279
  response = self._stage_success_response(
1201
1280
  build_id=build_id,
1202
1281
  stage_name=public_stage_name,
@@ -1208,11 +1287,12 @@ class SolutionTools(ToolBase):
1208
1287
  errors=result["errors"],
1209
1288
  run_path=result["run_path"],
1210
1289
  build_path=str(assembly.path),
1211
- status=result["status"],
1290
+ status=effective_status,
1212
1291
  build_summary=_build_summary(assembly),
1213
1292
  tool_name=tool_name,
1293
+ verification=stage_verification,
1214
1294
  )
1215
- if result["status"] == "failed":
1295
+ if effective_status == "failed":
1216
1296
  response["failure_signature"] = failure_signature
1217
1297
  response["stage_failure_count"] = stage_failure_count or 1
1218
1298
  if next_strategy:
@@ -1415,6 +1495,100 @@ class SolutionTools(ToolBase):
1415
1495
  verification["status"] = "partial"
1416
1496
  return verification
1417
1497
 
1498
+ def _verify_stage_write_result(
1499
+ self,
1500
+ *,
1501
+ profile: str,
1502
+ build_id: str,
1503
+ public_stage_name: str,
1504
+ artifacts: dict[str, Any],
1505
+ solution_spec: dict[str, Any],
1506
+ target: dict[str, Any],
1507
+ ) -> dict[str, Any] | None:
1508
+ if public_stage_name != "app":
1509
+ return None
1510
+ app_tools = AppTools(self.sessions, self.backend)
1511
+ expected_package_tag_id = _resolve_target_package_tag_id(target)
1512
+ verification: dict[str, Any] = {
1513
+ "stage": public_stage_name,
1514
+ "status": "success",
1515
+ "expected_package_tag_id": expected_package_tag_id,
1516
+ "package_attached": None if expected_package_tag_id is None else True,
1517
+ "apps": [],
1518
+ "views_created": [],
1519
+ "views_strategy": "not_included",
1520
+ "errors": [],
1521
+ }
1522
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
1523
+ views_artifact = artifacts.get("views", {}) if isinstance(artifacts.get("views"), dict) else {}
1524
+ entity_specs = {
1525
+ entity.get("entity_id"): entity
1526
+ for entity in solution_spec.get("entities", [])
1527
+ if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str)
1528
+ }
1529
+
1530
+ for entity_id, app_info in apps_artifact.items():
1531
+ if not isinstance(app_info, dict):
1532
+ continue
1533
+ app_key = app_info.get("app_key")
1534
+ if not isinstance(app_key, str) or not app_key:
1535
+ continue
1536
+ try:
1537
+ base_info = app_tools.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1538
+ raw_tag_ids = base_info.get("tagIds") if isinstance(base_info.get("tagIds"), list) else []
1539
+ tag_ids_after = [tag_id for tag_id in (_coerce_count(value) for value in raw_tag_ids) if tag_id is not None]
1540
+ package_attached = None if expected_package_tag_id is None else expected_package_tag_id in tag_ids_after
1541
+ if package_attached is False:
1542
+ verification["status"] = "partial"
1543
+ verification["package_attached"] = False
1544
+ verification["errors"].append(
1545
+ {
1546
+ "category": "verification",
1547
+ "detail": f"app '{app_key}' is not attached to expected package '{expected_package_tag_id}'",
1548
+ "entity_id": entity_id,
1549
+ "app_key": app_key,
1550
+ "tag_ids_after": tag_ids_after,
1551
+ }
1552
+ )
1553
+ entity_views = views_artifact.get(entity_id) if isinstance(views_artifact.get(entity_id), dict) else {}
1554
+ created_view_keys = sorted(
1555
+ {
1556
+ str(view_result.get("view_key"))
1557
+ for view_result in entity_views.values()
1558
+ if isinstance(view_result, dict) and isinstance(view_result.get("view_key"), str) and view_result.get("view_key")
1559
+ }
1560
+ )
1561
+ verification["views_created"].extend(created_view_keys)
1562
+ verification["apps"].append(
1563
+ {
1564
+ "entity_id": entity_id,
1565
+ "display_name": entity_specs.get(entity_id, {}).get("display_name"),
1566
+ "app_key": app_key,
1567
+ "tag_ids_after": tag_ids_after,
1568
+ "package_attached": package_attached,
1569
+ "views_created": created_view_keys,
1570
+ "publish_status": base_info.get("appPublishStatus"),
1571
+ }
1572
+ )
1573
+ except Exception as exc: # noqa: BLE001
1574
+ verification["status"] = "partial"
1575
+ verification["errors"].append(
1576
+ {
1577
+ "category": "verification",
1578
+ "detail": str(exc),
1579
+ "entity_id": entity_id,
1580
+ "app_key": app_key,
1581
+ }
1582
+ )
1583
+ if verification["views_created"]:
1584
+ verification["views_strategy"] = "created"
1585
+ else:
1586
+ verification["suggested_next_call"] = {
1587
+ "tool_name": "solution_build_views",
1588
+ "arguments": {"mode": "plan", "build_id": build_id},
1589
+ }
1590
+ return verification
1591
+
1418
1592
  def _failure_response(self, mode: str, idempotency_key: str, category: str, detail: Any) -> dict[str, Any]:
1419
1593
  error_fields = _solution_error_fields(
1420
1594
  category=category,
@@ -1458,6 +1632,8 @@ class SolutionTools(ToolBase):
1458
1632
  ),
1459
1633
  stage=stage_name,
1460
1634
  )
1635
+ if build_id and stage_name == "app":
1636
+ error_fields["suggested_schema_call"] = None
1461
1637
  return {
1462
1638
  "build_id": build_id,
1463
1639
  "stage": stage_name,
@@ -1521,8 +1697,9 @@ class SolutionTools(ToolBase):
1521
1697
  status: str,
1522
1698
  build_summary: dict[str, Any],
1523
1699
  tool_name: str,
1700
+ verification: dict[str, Any] | None = None,
1524
1701
  ) -> dict[str, Any]:
1525
- return {
1702
+ response = {
1526
1703
  "build_id": build_id,
1527
1704
  "stage": stage_name,
1528
1705
  "mode": mode,
@@ -1535,6 +1712,7 @@ class SolutionTools(ToolBase):
1535
1712
  "errors": _sanitize_errors(errors),
1536
1713
  "build_path": build_path,
1537
1714
  "run_path": run_path,
1715
+ "verification": verification,
1538
1716
  "build_summary": build_summary,
1539
1717
  "suggested_next_call": _solution_build_next_call(
1540
1718
  tool_name=tool_name,
@@ -1542,6 +1720,11 @@ class SolutionTools(ToolBase):
1542
1720
  build_id=build_id,
1543
1721
  ),
1544
1722
  }
1723
+ if isinstance(verification, dict):
1724
+ for key in ("package_attached", "views_created", "views_strategy"):
1725
+ if key in verification:
1726
+ response[key] = verification.get(key)
1727
+ return response
1545
1728
 
1546
1729
  def _design_failure_response(self, session_id: str, action: str, category: str, detail: Any) -> dict[str, Any]:
1547
1730
  error_fields = _solution_error_fields(
@@ -1761,6 +1944,116 @@ class SolutionTools(ToolBase):
1761
1944
  },
1762
1945
  }
1763
1946
 
1947
+ def _resolve_builder_existing_app_reference(
1948
+ self,
1949
+ *,
1950
+ profile: str,
1951
+ app_key: str,
1952
+ package_resolution: dict[str, Any],
1953
+ update_only: bool,
1954
+ ) -> dict[str, Any]:
1955
+ normalized_app_key = str(app_key or "").strip()
1956
+ if not normalized_app_key:
1957
+ if update_only:
1958
+ detail = "update target requires app_key"
1959
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
1960
+ error_fields["missing_required_fields"] = sorted(
1961
+ dict.fromkeys(list(error_fields.get("missing_required_fields", [])) + ["app_key"])
1962
+ )
1963
+ error_fields["suggested_schema_call"] = None
1964
+ return {
1965
+ "status": "failed",
1966
+ "response": {
1967
+ "status": "failed",
1968
+ "stage": "app",
1969
+ "errors": [{"category": "config", "detail": detail}],
1970
+ **error_fields,
1971
+ },
1972
+ }
1973
+ return {"status": "none"}
1974
+
1975
+ app_tools = AppTools(self.sessions, self.backend)
1976
+ try:
1977
+ base_info = app_tools.app_get_base(profile=profile, app_key=normalized_app_key, include_raw=True).get("result") or {}
1978
+ except Exception as exc: # noqa: BLE001
1979
+ detail = f"failed to resolve existing app '{normalized_app_key}': {exc}"
1980
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
1981
+ error_fields["suggested_schema_call"] = None
1982
+ return {
1983
+ "status": "failed",
1984
+ "response": {
1985
+ "status": "failed",
1986
+ "stage": "app",
1987
+ "errors": [{"category": "config", "detail": detail}],
1988
+ **error_fields,
1989
+ },
1990
+ }
1991
+
1992
+ raw_tag_ids = base_info.get("tagIds") if isinstance(base_info.get("tagIds"), list) else []
1993
+ tag_ids = [tag_id for tag_id in (_coerce_count(item) for item in raw_tag_ids) if tag_id is not None]
1994
+ effective_package_resolution = deepcopy(package_resolution)
1995
+ if package_resolution.get("status") == "resolved":
1996
+ resolved_tag_id = _coerce_count(package_resolution.get("tag_id"))
1997
+ if resolved_tag_id is not None and tag_ids and resolved_tag_id not in tag_ids:
1998
+ detail = (
1999
+ f"app_key '{normalized_app_key}' does not belong to requested package "
2000
+ f"'{package_resolution.get('tag_name') or resolved_tag_id}'"
2001
+ )
2002
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2003
+ error_fields["error_code"] = "APP_PACKAGE_MISMATCH"
2004
+ error_fields["suggested_schema_call"] = None
2005
+ return {
2006
+ "status": "failed",
2007
+ "response": {
2008
+ "status": "failed",
2009
+ "stage": "app",
2010
+ "errors": [{"category": "config", "detail": detail}],
2011
+ "existing_app": {
2012
+ "app_key": normalized_app_key,
2013
+ "app_name": base_info.get("formTitle"),
2014
+ "tag_ids": tag_ids,
2015
+ },
2016
+ **error_fields,
2017
+ },
2018
+ }
2019
+ elif package_resolution.get("status") == "new_package":
2020
+ if len(tag_ids) == 1:
2021
+ package_tools = PackageTools(self.sessions, self.backend)
2022
+ package_detail = package_tools.package_get(profile=profile, tag_id=tag_ids[0], include_raw=False).get("result") or {}
2023
+ effective_package_resolution = {
2024
+ "status": "resolved",
2025
+ "matched_via": "existing_app",
2026
+ "tag_id": tag_ids[0],
2027
+ "tag_name": package_detail.get("tagName"),
2028
+ "candidates": [],
2029
+ }
2030
+ elif len(tag_ids) > 1:
2031
+ detail = f"app_key '{normalized_app_key}' belongs to multiple packages; provide package_tag_id or package_name explicitly"
2032
+ error_fields = _solution_error_fields(category="config", detail=detail, suggested_next_call=None, stage="app")
2033
+ error_fields["error_code"] = "AMBIGUOUS_PACKAGE"
2034
+ error_fields["suggested_schema_call"] = None
2035
+ return {
2036
+ "status": "failed",
2037
+ "response": {
2038
+ "status": "failed",
2039
+ "stage": "app",
2040
+ "errors": [{"category": "config", "detail": detail}],
2041
+ "existing_app": {
2042
+ "app_key": normalized_app_key,
2043
+ "app_name": base_info.get("formTitle"),
2044
+ "tag_ids": tag_ids,
2045
+ },
2046
+ **error_fields,
2047
+ },
2048
+ }
2049
+ return {
2050
+ "status": "resolved",
2051
+ "app_key": normalized_app_key,
2052
+ "app_name": base_info.get("formTitle"),
2053
+ "package_resolution": effective_package_resolution,
2054
+ "tag_ids": tag_ids,
2055
+ }
2056
+
1764
2057
  def _requirements_failure_response(
1765
2058
  self,
1766
2059
  *,
@@ -1769,6 +2062,8 @@ class SolutionTools(ToolBase):
1769
2062
  title: str,
1770
2063
  requirement_text: str,
1771
2064
  package_resolution: dict[str, Any],
2065
+ app_key: str,
2066
+ update_only: bool,
1772
2067
  layout_style: str,
1773
2068
  publish: bool,
1774
2069
  run_label: str | None,
@@ -1788,6 +2083,8 @@ class SolutionTools(ToolBase):
1788
2083
  title=title,
1789
2084
  requirement_text=requirement_text,
1790
2085
  package_resolution=package_resolution,
2086
+ app_key=app_key,
2087
+ update_only=update_only,
1791
2088
  layout_style=layout_style,
1792
2089
  publish=publish,
1793
2090
  run_label=run_label,
@@ -1823,6 +2120,8 @@ class SolutionTools(ToolBase):
1823
2120
  "failure_signature": None,
1824
2121
  "stage_failure_count": 0,
1825
2122
  "details": deepcopy(error_details or {}),
2123
+ "suggested_requirement_hint": (error_details or {}).get("suggested_requirement_hint") if isinstance(error_details, dict) else None,
2124
+ "suggested_patch": deepcopy((error_details or {}).get("suggested_patch")) if isinstance(error_details, dict) else None,
1826
2125
  **error_fields,
1827
2126
  }
1828
2127
 
@@ -1857,7 +2156,21 @@ class SolutionTools(ToolBase):
1857
2156
  "excluded_advanced_fields": generated.summary.get("excluded_advanced_fields"),
1858
2157
  "execution_step_count": len((stage_result.get("execution_plan") or {}).get("steps", [])),
1859
2158
  "suggested_next_call": next_call,
2159
+ "views_strategy": _resolve_app_stage_views_strategy(stage_result),
1860
2160
  }
2161
+ if isinstance(stage_result.get("verification"), dict):
2162
+ verification = stage_result["verification"]
2163
+ response["verification"] = verification
2164
+ if "package_attached" in verification:
2165
+ response["package_attached"] = verification.get("package_attached")
2166
+ if isinstance(verification.get("apps"), list):
2167
+ response["tag_ids_after"] = {
2168
+ str(item.get("entity_id")): item.get("tag_ids_after")
2169
+ for item in verification["apps"]
2170
+ if isinstance(item, dict) and item.get("entity_id") is not None
2171
+ }
2172
+ if isinstance(verification.get("views_created"), list):
2173
+ response["views_created"] = verification.get("views_created")
1861
2174
  for key in (
1862
2175
  "error_code",
1863
2176
  "recoverable",
@@ -1967,6 +2280,8 @@ def _requirements_next_call(
1967
2280
  title: str,
1968
2281
  requirement_text: str,
1969
2282
  package_resolution: dict[str, Any],
2283
+ app_key: str,
2284
+ update_only: bool,
1970
2285
  layout_style: str,
1971
2286
  publish: bool,
1972
2287
  run_label: str | None,
@@ -1986,6 +2301,10 @@ def _requirements_next_call(
1986
2301
  arguments["package_tag_id"] = package_resolution["tag_id"]
1987
2302
  elif isinstance(package_resolution.get("tag_name"), str) and package_resolution["tag_name"]:
1988
2303
  arguments["package_name"] = package_resolution["tag_name"]
2304
+ if isinstance(app_key, str) and app_key:
2305
+ arguments["app_key"] = app_key
2306
+ if update_only or arguments.get("app_key"):
2307
+ arguments["update_only"] = True
1989
2308
  return {"tool_name": "solution_build_app_from_requirements", "arguments": arguments}
1990
2309
 
1991
2310
 
@@ -1995,6 +2314,8 @@ def _rewrite_requirements_next_call(
1995
2314
  title: str,
1996
2315
  requirement_text: str,
1997
2316
  package_resolution: dict[str, Any],
2317
+ app_key: str,
2318
+ update_only: bool,
1998
2319
  layout_style: str,
1999
2320
  publish: bool,
2000
2321
  run_label: str | None,
@@ -2014,17 +2335,93 @@ def _rewrite_requirements_next_call(
2014
2335
  title=title,
2015
2336
  requirement_text=requirement_text,
2016
2337
  package_resolution=package_resolution,
2338
+ app_key=app_key,
2339
+ update_only=update_only,
2017
2340
  layout_style=layout_style,
2018
2341
  publish=publish,
2019
2342
  run_label=run_label,
2020
2343
  )
2021
2344
 
2022
2345
 
2023
- def _requirements_target(package_resolution: dict[str, Any]) -> dict[str, Any]:
2346
+ def _requirements_target(
2347
+ package_resolution: dict[str, Any],
2348
+ *,
2349
+ app_key: str = "",
2350
+ update_only: bool = False,
2351
+ ) -> dict[str, Any]:
2024
2352
  tag_id = package_resolution.get("tag_id")
2353
+ target: dict[str, Any] = {}
2025
2354
  if isinstance(tag_id, int) and tag_id > 0:
2026
- return {"package_tag_id": tag_id}
2027
- return {}
2355
+ target["package_tag_id"] = tag_id
2356
+ if isinstance(app_key, str) and app_key:
2357
+ target["app_key"] = app_key
2358
+ if update_only or target.get("app_key"):
2359
+ target["mode"] = "update"
2360
+ target["update_only"] = True
2361
+ return target
2362
+
2363
+
2364
+ def _merge_app_target(
2365
+ target: dict[str, Any] | None,
2366
+ *,
2367
+ app_key: str,
2368
+ package_tag_id: int,
2369
+ update_only: bool,
2370
+ ) -> dict[str, Any]:
2371
+ merged = deepcopy(target) if isinstance(target, dict) else {}
2372
+ if isinstance(app_key, str) and app_key.strip():
2373
+ merged["app_key"] = app_key.strip()
2374
+ if isinstance(package_tag_id, int) and package_tag_id > 0:
2375
+ merged["package_tag_id"] = package_tag_id
2376
+ if update_only or merged.get("app_key"):
2377
+ merged["mode"] = "update"
2378
+ merged["update_only"] = True
2379
+ return merged
2380
+
2381
+
2382
+ def _target_is_update_only(target: dict[str, Any]) -> bool:
2383
+ if not target:
2384
+ return False
2385
+ if target.get("update_only") is True:
2386
+ return True
2387
+ mode = target.get("mode")
2388
+ return isinstance(mode, str) and mode.strip().lower() == "update"
2389
+
2390
+
2391
+ def _resolve_target_app_keys(*, target: dict[str, Any], entity_ids: list[str]) -> dict[str, str]:
2392
+ if not target:
2393
+ return {}
2394
+ resolved: dict[str, str] = {}
2395
+ direct_app_key = target.get("app_key")
2396
+ if isinstance(direct_app_key, str) and direct_app_key.strip():
2397
+ if len(entity_ids) != 1:
2398
+ raise ValueError("target.app_key requires a single-entity app stage; use target.app_keys for multiple entities")
2399
+ resolved[entity_ids[0]] = direct_app_key.strip()
2400
+ raw_mapping = target.get("app_keys")
2401
+ if isinstance(raw_mapping, dict):
2402
+ for entity_id, app_key in raw_mapping.items():
2403
+ if not isinstance(entity_id, str) or entity_id not in entity_ids:
2404
+ raise ValueError(f"target.app_keys contains unknown entity '{entity_id}'")
2405
+ if not isinstance(app_key, str) or not app_key.strip():
2406
+ raise ValueError(f"target.app_keys['{entity_id}'] must be a non-empty string")
2407
+ resolved[entity_id] = app_key.strip()
2408
+ return resolved
2409
+
2410
+
2411
+ def _seed_existing_app_artifacts(*, assembly: BuildAssemblyStore, existing_app_targets: dict[str, str]) -> None:
2412
+ if not existing_app_targets:
2413
+ return
2414
+ artifacts = assembly.get_artifacts()
2415
+ apps_artifact = artifacts.get("apps", {}) if isinstance(artifacts.get("apps"), dict) else {}
2416
+ for entity_id, app_key in existing_app_targets.items():
2417
+ existing = apps_artifact.get(entity_id, {}) if isinstance(apps_artifact.get(entity_id), dict) else {}
2418
+ next_artifact = deepcopy(existing)
2419
+ next_artifact["app_key"] = app_key
2420
+ next_artifact["reused"] = True
2421
+ next_artifact["target_mode"] = "update"
2422
+ apps_artifact[entity_id] = next_artifact
2423
+ artifacts["apps"] = apps_artifact
2424
+ assembly.set_artifacts(artifacts)
2028
2425
 
2029
2426
 
2030
2427
  def _design_session_next_call(*, action: str, session_id: str | None) -> dict[str, Any] | None:
@@ -2076,6 +2473,7 @@ def _solution_schema_example_payload(
2076
2473
  tool_name, payload_key = _solution_schema_tool_metadata(stage)
2077
2474
  examples = _solution_schema_examples(stage)
2078
2475
  selected = deepcopy(examples.get(intent) or examples["minimal"])
2476
+ call_example = _solution_schema_call_example(stage)
2079
2477
  response = {
2080
2478
  "status": "ok",
2081
2479
  "stage": stage,
@@ -2087,10 +2485,8 @@ def _solution_schema_example_payload(
2087
2485
  "optional_fields": _solution_schema_optional_fields(stage),
2088
2486
  "common_errors": _solution_schema_common_errors(stage),
2089
2487
  "notes": _solution_schema_notes(stage),
2090
- "suggested_next_call": {
2091
- "tool_name": tool_name,
2092
- "arguments": {"mode": "preflight"},
2093
- },
2488
+ "call_example": call_example,
2489
+ "suggested_next_call": deepcopy(call_example),
2094
2490
  }
2095
2491
  if include_selected_example:
2096
2492
  response["selected_example"] = selected
@@ -2102,6 +2498,7 @@ def _solution_schema_example_payload(
2102
2498
  def _solution_schema_tool_metadata(stage: str) -> tuple[str, str]:
2103
2499
  mapping = {
2104
2500
  "app": ("solution_build_app", "app_spec"),
2501
+ "app_update": ("solution_build_app", "app_spec"),
2105
2502
  "flow": ("solution_build_flow", "flow_spec"),
2106
2503
  "views": ("solution_build_views", "views_spec"),
2107
2504
  "analytics_portal": ("solution_build_analytics_portal", "analytics_portal_spec"),
@@ -2112,9 +2509,18 @@ def _solution_schema_tool_metadata(stage: str) -> tuple[str, str]:
2112
2509
  return mapping[stage]
2113
2510
 
2114
2511
 
2512
+ def _solution_schema_call_example(stage: str) -> dict[str, Any]:
2513
+ tool_name, _payload_key = _solution_schema_tool_metadata(stage)
2514
+ arguments: dict[str, Any] = {"mode": "preflight"}
2515
+ if stage == "app_update":
2516
+ arguments.update({"app_key": "APP_123456", "update_only": True})
2517
+ return {"tool_name": tool_name, "arguments": arguments}
2518
+
2519
+
2115
2520
  def _solution_schema_required_fields(stage: str) -> list[str]:
2116
2521
  fields = {
2117
2522
  "app": ["solution_name", "entities[].entity_id", "entities[].display_name", "entities[].kind", "entities[].fields[]"],
2523
+ "app_update": ["solution_name", "entities[].entity_id", "entities[].display_name", "entities[].kind", "entities[].fields[]", "app_key"],
2118
2524
  "flow": ["entities[].entity_id", "entities[].workflow or entities[].lifecycle_stages"],
2119
2525
  "views": ["entities[].entity_id", "entities[].views[]"],
2120
2526
  "analytics_portal": ["entities[].entity_id or portal.sections[]"],
@@ -2128,6 +2534,7 @@ def _solution_schema_required_fields(stage: str) -> list[str]:
2128
2534
  def _solution_schema_optional_fields(stage: str) -> list[str]:
2129
2535
  fields = {
2130
2536
  "app": ["summary", "business_context", "package", "roles", "publish_policy", "preferences", "entities[].form_layout", "entities[].sample_records"],
2537
+ "app_update": ["package_tag_id", "publish_policy", "preferences", "entities[].form_layout", "entities[].sample_records", "update_only"],
2131
2538
  "flow": ["solution_name", "roles", "publish_policy", "entities[].status_field_id", "entities[].owner_field_id", "entities[].start_field_id", "entities[].end_field_id"],
2132
2539
  "views": ["solution_name", "metadata"],
2133
2540
  "analytics_portal": ["solution_name", "publish_policy", "portal.name", "portal.sections[]"],
@@ -2145,6 +2552,11 @@ def _solution_schema_common_errors(stage: str) -> list[str]:
2145
2552
  "Each field_id must be unique within an entity.",
2146
2553
  "Do not include workflow/views/charts in app_spec. Use solution_build_flow or later stage tools.",
2147
2554
  ],
2555
+ "app_update": [
2556
+ "app_key is required for update mode and must point to an existing app.",
2557
+ "Updating an app uses the same app_spec structure, but the tool target must stay in update mode.",
2558
+ "Do not omit entity_id; updates still need a deterministic entity identity.",
2559
+ ],
2148
2560
  "flow": [
2149
2561
  "The target entity must already exist in the build manifest or from a prior solution_build_app run.",
2150
2562
  "status_field_id must point to an existing field, usually 'status'.",
@@ -2177,6 +2589,10 @@ def _solution_schema_notes(stage: str) -> list[str]:
2177
2589
  "Use solution_build_app for package, entity, field, layout, and sample record design.",
2178
2590
  "If you are unsure, call solution_schema_example(stage='app', intent='minimal') before generating payloads.",
2179
2591
  ],
2592
+ "app_update": [
2593
+ "Use solution_build_app with app_key and update_only=true when modifying an existing app.",
2594
+ "Update mode is not allowed to fall back to new package or new app creation.",
2595
+ ],
2180
2596
  "flow": [
2181
2597
  "Use solution_build_flow only after app/schema exists.",
2182
2598
  "workflow.nodes uses node_type enum values like start, audit, fill, copy, branch, condition.",
@@ -2268,6 +2684,62 @@ def _solution_schema_examples(stage: str) -> dict[str, dict[str, Any]]:
2268
2684
  ],
2269
2685
  },
2270
2686
  },
2687
+ "app_update": {
2688
+ "minimal": {
2689
+ "solution_name": "Demo Workspace",
2690
+ "entities": [
2691
+ {
2692
+ "entity_id": "demo_request",
2693
+ "display_name": "测试申请",
2694
+ "kind": "transaction",
2695
+ "title_field_id": "title",
2696
+ "fields": [
2697
+ {"field_id": "title", "label": "工单标题", "type": "text", "required": True},
2698
+ ],
2699
+ "form_layout": {"rows": [{"field_ids": ["title"]}]},
2700
+ }
2701
+ ],
2702
+ },
2703
+ "full": {
2704
+ "solution_name": "Demo Workspace",
2705
+ "entities": [
2706
+ {
2707
+ "entity_id": "demo_request",
2708
+ "display_name": "测试申请",
2709
+ "kind": "transaction",
2710
+ "title_field_id": "title",
2711
+ "status_field_id": "status",
2712
+ "fields": [
2713
+ {"field_id": "title", "label": "工单标题", "type": "text", "required": True},
2714
+ {"field_id": "status", "label": "状态", "type": "single_select", "options": ["待处理", "处理中", "已完成"]},
2715
+ {"field_id": "priority", "label": "优先级", "type": "single_select", "options": ["P0", "P1", "P2"]},
2716
+ ],
2717
+ "form_layout": {
2718
+ "sections": [
2719
+ {"section_id": "basic", "title": "基础信息", "rows": [{"field_ids": ["title", "status"]}, {"field_ids": ["priority"]}]}
2720
+ ]
2721
+ },
2722
+ }
2723
+ ],
2724
+ },
2725
+ "demo": {
2726
+ "solution_name": "Demo Workspace",
2727
+ "entities": [
2728
+ {
2729
+ "entity_id": "customer_order",
2730
+ "display_name": "客户订单",
2731
+ "kind": "transaction",
2732
+ "title_field_id": "title",
2733
+ "fields": [
2734
+ {"field_id": "title", "label": "订单标题", "type": "text", "required": True},
2735
+ {"field_id": "customer_name", "label": "客户名称", "type": "text"},
2736
+ {"field_id": "amount", "label": "订单金额", "type": "amount"},
2737
+ ],
2738
+ "form_layout": {"rows": [{"field_ids": ["title", "customer_name"]}, {"field_ids": ["amount"]}]},
2739
+ }
2740
+ ],
2741
+ },
2742
+ },
2271
2743
  "flow": {
2272
2744
  "minimal": {
2273
2745
  "solution_name": "Demo Workspace",
@@ -3054,6 +3526,15 @@ def _extract_navigation_items(items: list[Any]) -> list[dict[str, Any]]:
3054
3526
  return extracted
3055
3527
 
3056
3528
 
3529
+ def _resolve_app_stage_views_strategy(stage_result: dict[str, Any]) -> str:
3530
+ verification = stage_result.get("verification")
3531
+ if isinstance(verification, dict):
3532
+ strategy = verification.get("views_strategy")
3533
+ if isinstance(strategy, str) and strategy:
3534
+ return strategy
3535
+ return "not_included"
3536
+
3537
+
3057
3538
  def _resolve_target_package_tag_id(target: dict[str, Any]) -> int | None:
3058
3539
  if not target:
3059
3540
  return None
@@ -3069,12 +3550,18 @@ def _resolve_target_package_tag_id(target: dict[str, Any]) -> int | None:
3069
3550
 
3070
3551
  def _bind_existing_package_target(compiled: CompiledSolution, package_tag_id: int) -> CompiledSolution:
3071
3552
  compiled.package_payload = None
3072
- compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False)
3553
+ compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False, attach_package=True)
3073
3554
  for entity in compiled.entities:
3074
3555
  entity.app_create_payload["tagIds"] = [package_tag_id]
3075
3556
  return compiled
3076
3557
 
3077
3558
 
3559
+ def _bind_existing_app_target(compiled: CompiledSolution) -> CompiledSolution:
3560
+ compiled.package_payload = None
3561
+ compiled.execution_plan = build_execution_plan(compiled.normalized_spec, include_package=False, attach_package=False)
3562
+ return compiled
3563
+
3564
+
3078
3565
  def _merge_stage_manifest(*, stage_name: str, manifest: dict[str, Any], stage_payload: dict[str, Any]) -> dict[str, Any]:
3079
3566
  merged = deepcopy(manifest or default_manifest())
3080
3567
  if stage_name == "app_flow":
@@ -3226,6 +3713,7 @@ def _stage_step_names(
3226
3713
  step.step_name.startswith(prefix)
3227
3714
  for prefix in (
3228
3715
  "app.create.",
3716
+ "package.attach.",
3229
3717
  "form.base.",
3230
3718
  "form.relations.",
3231
3719
  "publish.form.",
@@ -3257,6 +3745,7 @@ def _stage_step_names(
3257
3745
  step.step_name.startswith(prefix)
3258
3746
  for prefix in (
3259
3747
  "app.create.",
3748
+ "package.attach.",
3260
3749
  "form.base.",
3261
3750
  "form.relations.",
3262
3751
  "workflow.",