@josephyan/qingflow-app-builder-mcp 1.1.21 → 1.1.22

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.
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
4
  from copy import deepcopy
4
5
  import json
5
6
  import time
@@ -15,7 +16,7 @@ from ..builder_facade.button_style_catalog import (
15
16
  )
16
17
  from ..public_surface import public_builder_contract_tool_names
17
18
  from ..config import DEFAULT_PROFILE
18
- from ..errors import QingflowApiError, backend_code_int
19
+ from ..errors import QingflowApiError, backend_code_int, backend_code_value_int
19
20
  from ..json_types import JSONObject
20
21
  from ..builder_facade.models import (
21
22
  AssociatedResourcesApplyRequest,
@@ -142,6 +143,7 @@ BUILDER_APPLY_TOOL_NAMES = {
142
143
  "portal_delete",
143
144
  "app_publish_verify",
144
145
  }
146
+ MULTI_APP_SCHEMA_APPLY_PARALLEL_LIMIT = 10
145
147
 
146
148
 
147
149
  class AiBuilderTools(ToolBase):
@@ -171,13 +173,28 @@ class AiBuilderTools(ToolBase):
171
173
  )
172
174
 
173
175
  def _apply_app_batch(self, *, tool_name: str, profile: str, apps: list[JSONObject], apply_one) -> JSONObject:
176
+ started_at = time.perf_counter()
177
+ batch_item_apply_ms = 0
178
+ child_total_ms = 0
179
+
180
+ def finish(payload: JSONObject) -> JSONObject:
181
+ _merge_duration_breakdown(
182
+ payload,
183
+ batch_item_apply_ms=batch_item_apply_ms,
184
+ child_total_ms=child_total_ms,
185
+ total_ms=_elapsed_ms(started_at),
186
+ )
187
+ return _attach_builder_apply_envelope(tool_name, payload)
188
+
174
189
  if not isinstance(apps, list) or not apps:
175
- return _attach_builder_apply_envelope(tool_name, _config_failure(
176
- tool_name=tool_name,
177
- message=f"{tool_name} batch mode requires non-empty apps[].",
178
- fix_hint="Pass apps as a JSON array; each item must include app_key plus that resource's normal payload.",
179
- details={"expected_shape": {"apps": [{"app_key": "APP_KEY"}]}},
180
- ))
190
+ return finish(
191
+ _config_failure(
192
+ tool_name=tool_name,
193
+ message=f"{tool_name} batch mode requires non-empty apps[].",
194
+ fix_hint="Pass apps as a JSON array; each item must include app_key plus that resource's normal payload.",
195
+ details={"expected_shape": {"apps": [{"app_key": "APP_KEY"}]}},
196
+ )
197
+ )
181
198
 
182
199
  app_results: list[JSONObject] = []
183
200
  errors: list[JSONObject] = []
@@ -206,8 +223,10 @@ class AiBuilderTools(ToolBase):
206
223
  app_results.append(error)
207
224
  continue
208
225
  try:
226
+ item_started_at = time.perf_counter()
209
227
  result = apply_one(index, item, app_key)
210
228
  except (QingflowApiError, RuntimeError) as error:
229
+ batch_item_apply_ms += _elapsed_ms(item_started_at)
211
230
  api_error = _coerce_api_error(error)
212
231
  result = {
213
232
  "status": "failed",
@@ -221,6 +240,13 @@ class AiBuilderTools(ToolBase):
221
240
  "backend_code": api_error.backend_code,
222
241
  "http_status": None if api_error.http_status == 404 else api_error.http_status,
223
242
  }
243
+ else:
244
+ batch_item_apply_ms += _elapsed_ms(item_started_at)
245
+ if isinstance(result, dict):
246
+ child_breakdown = result.get("duration_breakdown_ms")
247
+ child_total = child_breakdown.get("total_ms") if isinstance(child_breakdown, dict) else None
248
+ if isinstance(child_total, (int, float)) and child_total >= 0:
249
+ child_total_ms += int(child_total)
224
250
  status = str(result.get("status") or "success") if isinstance(result, dict) else "failed"
225
251
  wrapped: JSONObject = {
226
252
  "index": index,
@@ -270,7 +296,7 @@ class AiBuilderTools(ToolBase):
270
296
  "write_succeeded": write_executed and failed == 0 and partial == 0,
271
297
  "safe_to_retry": not write_executed,
272
298
  }
273
- return _attach_builder_apply_envelope(tool_name, payload)
299
+ return finish(payload)
274
300
 
275
301
  def _read_app_batch(self, *, tool_name: str, profile: str, app_keys: list[str], read_one) -> JSONObject:
276
302
  normalized_keys = [str(item).strip() for item in app_keys if str(item).strip()]
@@ -356,7 +382,6 @@ class AiBuilderTools(ToolBase):
356
382
  profile: str = DEFAULT_PROFILE,
357
383
  package_id: int | None = None,
358
384
  package_name: str | None = None,
359
- create_if_missing: bool | None = None,
360
385
  icon: str | JSONObject | None = None,
361
386
  color: str | None = None,
362
387
  icon_name: str | None = None,
@@ -370,7 +395,6 @@ class AiBuilderTools(ToolBase):
370
395
  profile=profile,
371
396
  package_id=package_id,
372
397
  package_name=package_name,
373
- create_if_missing=create_if_missing,
374
398
  icon=icon,
375
399
  color=color,
376
400
  icon_name=icon_name,
@@ -599,7 +623,6 @@ class AiBuilderTools(ToolBase):
599
623
  icon_color: str | None = None,
600
624
  icon_config: JSONObject | None = None,
601
625
  visibility: JSONObject | None = None,
602
- create_if_missing: bool | None = None,
603
626
  publish: bool = True,
604
627
  form: list[JSONObject] | None = None,
605
628
  add_fields: list[JSONObject] | None = None,
@@ -611,7 +634,7 @@ class AiBuilderTools(ToolBase):
611
634
  if app_key or app_name or app_title or form or add_fields or update_fields or remove_fields:
612
635
  return _config_failure(
613
636
  tool_name="app_schema_apply",
614
- message="app_schema_apply multi-app mode accepts package_id plus apps only; create_if_missing is optional.",
637
+ message="app_schema_apply multi-app mode accepts package_id plus apps only.",
615
638
  fix_hint="Put per-app form in apps[].form when using batch mode.",
616
639
  )
617
640
  if package_id is None:
@@ -624,7 +647,6 @@ class AiBuilderTools(ToolBase):
624
647
  profile=profile,
625
648
  package_id=package_id,
626
649
  visibility=visibility,
627
- create_if_missing=True if create_if_missing is None else bool(create_if_missing),
628
650
  publish=publish,
629
651
  apps=apps,
630
652
  form=None,
@@ -636,20 +658,19 @@ class AiBuilderTools(ToolBase):
636
658
  has_app_name = bool((app_name or "").strip())
637
659
  has_app_title = bool((app_title or "").strip())
638
660
  has_package_id = package_id is not None
639
- effective_create_if_missing = bool(create_if_missing)
640
661
  if has_app_key:
641
- if effective_create_if_missing or has_package_id:
662
+ if has_package_id:
642
663
  return _config_failure(
643
664
  tool_name="app_schema_apply",
644
665
  message="app_schema_apply edit mode accepts app_key and optional app_name rename only.",
645
- fix_hint="For existing apps, use `app_key` and optionally `app_name`. For create mode, use `package_id + app_name + create_if_missing=true`.",
666
+ fix_hint="For existing apps, use `app_key` and optionally `app_name`. For create mode, use `package_id + app_name`.",
646
667
  )
647
- elif not (effective_create_if_missing and has_package_id and (has_app_name or has_app_title)):
668
+ elif not (has_package_id and (has_app_name or has_app_title)):
648
669
  return _config_failure(
649
670
  tool_name="app_schema_apply",
650
- message="app_schema_apply create mode requires package_id, app_name, and create_if_missing=true.",
651
- fix_hint="Use `app_key` for existing apps, or pass `package_id + app_name + create_if_missing=true` to create a new app.",
652
- )
671
+ message="app_schema_apply create mode requires package_id and app_name.",
672
+ fix_hint="Use `app_key` for existing apps, or pass `package_id + app_name` to create a new app.",
673
+ )
653
674
  return self.app_schema_apply(
654
675
  profile=profile,
655
676
  app_key=app_key,
@@ -662,7 +683,6 @@ class AiBuilderTools(ToolBase):
662
683
  icon_color=icon_color,
663
684
  icon_config=icon_config,
664
685
  visibility=visibility,
665
- create_if_missing=effective_create_if_missing,
666
686
  publish=publish,
667
687
  form=form or [],
668
688
  add_fields=add_fields or [],
@@ -1009,7 +1029,6 @@ class AiBuilderTools(ToolBase):
1009
1029
  profile: str,
1010
1030
  package_id: int | None = None,
1011
1031
  package_name: str | None = None,
1012
- create_if_missing: bool | None = None,
1013
1032
  icon: str | JSONObject | None = None,
1014
1033
  color: str | None = None,
1015
1034
  icon_name: str | None = None,
@@ -1040,14 +1059,13 @@ class AiBuilderTools(ToolBase):
1040
1059
  tool_name="package_apply",
1041
1060
  icon=icon,
1042
1061
  color=color,
1043
- creating=package_id is None and bool(create_if_missing),
1062
+ creating=package_id is None and bool(str(package_name or "").strip()),
1044
1063
  )
1045
1064
  if icon_failure is not None:
1046
1065
  return _attach_builder_apply_envelope("package_apply", icon_failure)
1047
1066
  normalized_args = {
1048
1067
  "package_id": package_id,
1049
1068
  **({"package_name": package_name} if str(package_name or "").strip() else {}),
1050
- "create_if_missing": bool(create_if_missing),
1051
1069
  **({"icon": icon} if icon else {}),
1052
1070
  **({"color": color} if color else {}),
1053
1071
  **({"visibility": visibility_patch.model_dump(mode="json")} if visibility_patch is not None else {}),
@@ -1059,7 +1077,6 @@ class AiBuilderTools(ToolBase):
1059
1077
  profile=profile,
1060
1078
  package_id=package_id,
1061
1079
  package_name=package_name,
1062
- create_if_missing=create_if_missing,
1063
1080
  icon=icon,
1064
1081
  color=color,
1065
1082
  visibility=visibility_patch,
@@ -1879,7 +1896,6 @@ class AiBuilderTools(ToolBase):
1879
1896
  icon: str = "",
1880
1897
  color: str = "",
1881
1898
  visibility: JSONObject | None = None,
1882
- create_if_missing: bool | None = None,
1883
1899
  add_fields: list[JSONObject],
1884
1900
  update_fields: list[JSONObject],
1885
1901
  remove_fields: list[JSONObject],
@@ -1894,7 +1910,6 @@ class AiBuilderTools(ToolBase):
1894
1910
  icon=icon,
1895
1911
  color=color,
1896
1912
  visibility=visibility,
1897
- create_if_missing=create_if_missing,
1898
1913
  add_fields=add_fields,
1899
1914
  update_fields=update_fields,
1900
1915
  remove_fields=remove_fields,
@@ -1910,7 +1925,6 @@ class AiBuilderTools(ToolBase):
1910
1925
  "icon": icon,
1911
1926
  "color": color,
1912
1927
  "visibility": visibility,
1913
- "create_if_missing": create_if_missing,
1914
1928
  "add_fields": add_fields,
1915
1929
  "update_fields": update_fields,
1916
1930
  "remove_fields": remove_fields,
@@ -1931,7 +1945,6 @@ class AiBuilderTools(ToolBase):
1931
1945
  "icon": icon,
1932
1946
  "color": color,
1933
1947
  "visibility": visibility,
1934
- "create_if_missing": create_if_missing,
1935
1948
  "add_fields": [{"name": "字段名称", "type": "text"}],
1936
1949
  "update_fields": [],
1937
1950
  "remove_fields": [],
@@ -2126,7 +2139,6 @@ class AiBuilderTools(ToolBase):
2126
2139
  icon_color: str | None = None,
2127
2140
  icon_config: JSONObject | None = None,
2128
2141
  visibility: JSONObject | None = None,
2129
- create_if_missing: bool | None = None,
2130
2142
  publish: bool = True,
2131
2143
  form: list[JSONObject] | None = None,
2132
2144
  add_fields: list[JSONObject],
@@ -2173,7 +2185,6 @@ class AiBuilderTools(ToolBase):
2173
2185
  profile=profile,
2174
2186
  package_id=package_id,
2175
2187
  visibility=visibility,
2176
- create_if_missing=True if create_if_missing is None else bool(create_if_missing),
2177
2188
  publish=publish,
2178
2189
  apps=apps,
2179
2190
  )
@@ -2207,7 +2218,6 @@ class AiBuilderTools(ToolBase):
2207
2218
  icon=icon,
2208
2219
  color=color,
2209
2220
  visibility=visibility,
2210
- create_if_missing=bool(create_if_missing),
2211
2221
  publish=publish,
2212
2222
  add_fields=add_fields,
2213
2223
  update_fields=update_fields,
@@ -2226,7 +2236,6 @@ class AiBuilderTools(ToolBase):
2226
2236
  icon=icon,
2227
2237
  color=color,
2228
2238
  visibility=visibility,
2229
- create_if_missing=create_if_missing,
2230
2239
  publish=publish,
2231
2240
  add_fields=add_fields,
2232
2241
  update_fields=update_fields,
@@ -2242,52 +2251,114 @@ class AiBuilderTools(ToolBase):
2242
2251
  profile: str,
2243
2252
  package_id: int | None,
2244
2253
  visibility: JSONObject | None,
2245
- create_if_missing: bool,
2246
2254
  publish: bool,
2247
2255
  apps: list[JSONObject],
2248
2256
  ) -> JSONObject:
2257
+ started_at = time.perf_counter()
2258
+ validation_ms = 0
2259
+ app_shell_apply_ms = 0
2260
+ relation_compile_ms = 0
2261
+ app_field_apply_ms = 0
2262
+ inline_layout_fallback_ms = 0
2263
+ child_schema_write_ms = 0
2264
+ child_inline_layout_ms = 0
2265
+ child_publish_ms = 0
2266
+ child_readback_ms = 0
2249
2267
  normalized_args: JSONObject = {
2250
2268
  "package_id": package_id,
2251
- "create_if_missing": create_if_missing,
2252
2269
  "publish": publish,
2253
2270
  "apps": deepcopy(apps),
2254
2271
  }
2255
2272
  if visibility is not None:
2256
2273
  normalized_args["visibility"] = deepcopy(visibility)
2274
+ parallelism: JSONObject = {"shell_workers": 0, "schema_workers": 0}
2275
+
2276
+ def merge_child_duration(child: JSONObject) -> None:
2277
+ nonlocal child_schema_write_ms
2278
+ nonlocal child_inline_layout_ms
2279
+ nonlocal child_publish_ms
2280
+ nonlocal child_readback_ms
2281
+ breakdown = child.get("duration_breakdown_ms") if isinstance(child, dict) else None
2282
+ if not isinstance(breakdown, dict):
2283
+ return
2284
+ for key, target in (
2285
+ ("schema_write_ms", "child_schema_write_ms"),
2286
+ ("inline_layout_ms", "child_inline_layout_ms"),
2287
+ ("publish_ms", "child_publish_ms"),
2288
+ ("readback_ms", "child_readback_ms"),
2289
+ ):
2290
+ value = breakdown.get(key)
2291
+ if isinstance(value, (int, float)) and value >= 0:
2292
+ if target == "child_schema_write_ms":
2293
+ child_schema_write_ms += int(value)
2294
+ elif target == "child_inline_layout_ms":
2295
+ child_inline_layout_ms += int(value)
2296
+ elif target == "child_publish_ms":
2297
+ child_publish_ms += int(value)
2298
+ elif target == "child_readback_ms":
2299
+ child_readback_ms += int(value)
2300
+
2301
+ def finish(response: JSONObject) -> JSONObject:
2302
+ _merge_duration_breakdown(
2303
+ response,
2304
+ multi_app_validation_ms=validation_ms,
2305
+ app_shell_apply_ms=app_shell_apply_ms,
2306
+ app_shell_parallel_wall_ms=app_shell_apply_ms,
2307
+ relation_compile_ms=relation_compile_ms,
2308
+ app_field_apply_ms=app_field_apply_ms,
2309
+ app_schema_parallel_wall_ms=app_field_apply_ms,
2310
+ native_inline_layout_ms=child_inline_layout_ms,
2311
+ inline_layout_fallback_ms=inline_layout_fallback_ms,
2312
+ child_schema_write_ms=child_schema_write_ms,
2313
+ child_inline_layout_ms=child_inline_layout_ms,
2314
+ child_publish_ms=child_publish_ms,
2315
+ child_readback_ms=child_readback_ms,
2316
+ total_ms=_elapsed_ms(started_at),
2317
+ )
2318
+ return response
2319
+
2320
+ validation_started_at = time.perf_counter()
2257
2321
  system_field_failure = _reserved_system_field_name_failure_for_apps(
2258
2322
  tool_name="app_schema_apply",
2259
2323
  profile=profile,
2260
2324
  package_id=package_id,
2261
2325
  visibility=visibility,
2262
- create_if_missing=create_if_missing,
2263
2326
  publish=publish,
2264
2327
  apps=apps,
2265
2328
  )
2266
2329
  if system_field_failure is not None:
2267
- return system_field_failure
2330
+ validation_ms += _elapsed_ms(validation_started_at)
2331
+ return finish(system_field_failure)
2268
2332
  if package_id is None:
2269
- return _config_failure(
2270
- tool_name="app_schema_apply",
2271
- message="app_schema_apply multi-app mode requires package_id.",
2272
- fix_hint="Pass package_id and apps[].app_name for new apps, or apps[].app_key for existing apps.",
2333
+ validation_ms += _elapsed_ms(validation_started_at)
2334
+ return finish(
2335
+ _config_failure(
2336
+ tool_name="app_schema_apply",
2337
+ message="app_schema_apply multi-app mode requires package_id.",
2338
+ fix_hint="Pass package_id and apps[].app_name for new apps, or apps[].app_key for existing apps.",
2339
+ )
2273
2340
  )
2274
2341
  if not apps:
2275
- return _config_failure(
2276
- tool_name="app_schema_apply",
2277
- error_code="APPS_FILE_EMPTY",
2278
- message="app_schema_apply multi-app mode requires non-empty apps.",
2279
- fix_hint="Pass apps as a non-empty list of app schema items.",
2342
+ validation_ms += _elapsed_ms(validation_started_at)
2343
+ return finish(
2344
+ _config_failure(
2345
+ tool_name="app_schema_apply",
2346
+ error_code="APPS_FILE_EMPTY",
2347
+ message="app_schema_apply multi-app mode requires non-empty apps.",
2348
+ fix_hint="Pass apps as a non-empty list of app schema items.",
2349
+ )
2280
2350
  )
2281
2351
  shape_failure = _schema_apps_shape_failure(tool_name="app_schema_apply", apps=apps)
2282
2352
  if shape_failure is not None:
2283
- return shape_failure
2353
+ validation_ms += _elapsed_ms(validation_started_at)
2354
+ return finish(shape_failure)
2284
2355
  static_validation_failure = _multi_app_static_validation_failure(
2285
2356
  tool_name="app_schema_apply",
2286
2357
  apps=apps,
2287
- create_if_missing=create_if_missing,
2288
2358
  )
2289
2359
  if static_validation_failure is not None:
2290
- return static_validation_failure
2360
+ validation_ms += _elapsed_ms(validation_started_at)
2361
+ return finish(static_validation_failure)
2291
2362
  icon_errors: list[JSONObject] = []
2292
2363
  seen_new_app_icons: dict[str, int] = {}
2293
2364
  for index, raw_item in enumerate(apps):
@@ -2338,32 +2409,111 @@ class AiBuilderTools(ToolBase):
2338
2409
  else:
2339
2410
  seen_new_app_icons[normalized_icon] = index
2340
2411
  if icon_errors:
2341
- return _config_failure(
2342
- tool_name="app_schema_apply",
2343
- error_code="WORKSPACE_ICON_BATCH_INVALID",
2344
- message="one or more apps have invalid workspace icon configuration",
2345
- fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
2346
- details={"icon_errors": icon_errors},
2347
- allowed_values={
2348
- "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
2349
- "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
2350
- "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
2351
- },
2412
+ validation_ms += _elapsed_ms(validation_started_at)
2413
+ return finish(
2414
+ _config_failure(
2415
+ tool_name="app_schema_apply",
2416
+ error_code="WORKSPACE_ICON_BATCH_INVALID",
2417
+ message="one or more apps have invalid workspace icon configuration",
2418
+ fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
2419
+ details={"icon_errors": icon_errors},
2420
+ allowed_values={
2421
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
2422
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
2423
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
2424
+ },
2425
+ )
2352
2426
  )
2427
+ validation_ms += _elapsed_ms(validation_started_at)
2353
2428
 
2354
2429
  client_key_to_app_key: dict[str, str] = {}
2355
2430
  app_name_to_app_key: dict[str, str] = {}
2356
2431
  created_app_keys: list[str] = []
2357
- results: list[JSONObject] = []
2432
+ results_by_index: list[JSONObject | None] = [None] * len(apps)
2433
+ shell_jobs: list[JSONObject] = []
2358
2434
  any_write_executed = False
2359
2435
  any_write_may_have_succeeded = False
2360
2436
  pending_readback_app_keys: list[str] = []
2361
2437
  pending_readback_app_names: list[str] = []
2362
2438
  client_keys: set[str] = set()
2439
+ client_key_indexes: dict[str, int] = {}
2440
+ app_name_indexes: dict[str, int] = {}
2441
+
2442
+ def worker_count(count: int) -> int:
2443
+ return min(MULTI_APP_SCHEMA_APPLY_PARALLEL_LIMIT, count) if count > 0 else 0
2444
+
2445
+ def mark_inline_layout_not_applied_in_schema(
2446
+ result: JSONObject,
2447
+ *,
2448
+ app_key: str,
2449
+ sections: list[JSONObject],
2450
+ ) -> JSONObject:
2451
+ marked = deepcopy(result)
2452
+ inline_payload: JSONObject = {
2453
+ "requested": True,
2454
+ "section_count": len(sections),
2455
+ "app_key": app_key or None,
2456
+ "applied": False,
2457
+ "applied_in_schema": False,
2458
+ "error_code": "INLINE_FORM_LAYOUT_NOT_APPLIED_IN_SCHEMA",
2459
+ "message": "schema save did not apply inline form layout; multi-app mode does not run app_layout_apply fallback",
2460
+ }
2461
+ marked["inline_form_layout"] = inline_payload
2462
+ verification = marked.get("verification") if isinstance(marked.get("verification"), dict) else {}
2463
+ verification = deepcopy(verification)
2464
+ verification["inline_form_layout_verified"] = False
2465
+ marked["verification"] = verification
2466
+ if marked.get("status") in {"success", "partial_success"}:
2467
+ marked["status"] = "partial_success"
2468
+ marked["error_code"] = marked.get("error_code") or "INLINE_FORM_LAYOUT_NOT_APPLIED_IN_SCHEMA"
2469
+ marked["message"] = "schema applied but inline form layout was not applied in schema save"
2470
+ marked["safe_to_retry"] = False
2471
+ return marked
2472
+
2473
+ def dependency_failure_for_item(index: int, item: JSONObject) -> JSONObject | None:
2474
+ blocked: list[JSONObject] = []
2475
+ for ref in _collect_multi_app_target_refs(item, path=f"apps[{index}]"):
2476
+ ref_value = str(ref.get("value") or "").strip()
2477
+ if not ref_value:
2478
+ continue
2479
+ target_index: int | None = None
2480
+ resolved = False
2481
+ if ref.get("kind") == "target_app_ref":
2482
+ target_index = client_key_indexes.get(ref_value)
2483
+ resolved = ref_value in client_key_to_app_key
2484
+ elif ref.get("kind") == "target_app":
2485
+ target_index = app_name_indexes.get(ref_value)
2486
+ resolved = ref_value in app_name_to_app_key
2487
+ if target_index is None or resolved:
2488
+ continue
2489
+ target_result = results_by_index[target_index] if 0 <= target_index < len(results_by_index) else None
2490
+ blocked.append(
2491
+ {
2492
+ "kind": ref.get("kind"),
2493
+ "value": ref_value,
2494
+ "path": ref.get("path"),
2495
+ "target_index": target_index,
2496
+ "target_status": target_result.get("status") if isinstance(target_result, dict) else None,
2497
+ "target_error_code": target_result.get("error_code") if isinstance(target_result, dict) else None,
2498
+ }
2499
+ )
2500
+ if not blocked:
2501
+ return None
2502
+ raw_item = apps[index] if 0 <= index < len(apps) else item
2503
+ failure = _multi_app_item_failure(
2504
+ index,
2505
+ raw_item,
2506
+ "DEPENDENCY_APP_NOT_READY",
2507
+ "one or more target apps failed or require readback, so this app schema was not written",
2508
+ )
2509
+ failure["stage"] = "dependency_check"
2510
+ failure["safe_to_retry"] = False
2511
+ failure["details"] = {"blocked_dependencies": blocked}
2512
+ return failure
2363
2513
 
2364
2514
  for index, raw_item in enumerate(apps):
2365
2515
  if not isinstance(raw_item, dict):
2366
- results.append(_multi_app_item_failure(index, raw_item, "INVALID_APP_ITEM", "apps[] items must be objects"))
2516
+ results_by_index[index] = _multi_app_item_failure(index, raw_item, "INVALID_APP_ITEM", "apps[] items must be objects")
2367
2517
  continue
2368
2518
  item = deepcopy(raw_item)
2369
2519
  client_key = str(item.get("client_key") or item.get("clientKey") or "").strip()
@@ -2371,33 +2521,104 @@ class AiBuilderTools(ToolBase):
2371
2521
  app_key = str(item.get("app_key") or item.get("appKey") or "").strip()
2372
2522
  if client_key:
2373
2523
  if client_key in client_keys:
2374
- results.append(_multi_app_item_failure(index, item, "DUPLICATE_CLIENT_KEY", f"duplicate client_key '{client_key}'"))
2524
+ results_by_index[index] = _multi_app_item_failure(index, item, "DUPLICATE_CLIENT_KEY", f"duplicate client_key '{client_key}'")
2375
2525
  continue
2376
2526
  client_keys.add(client_key)
2527
+ client_key_indexes[client_key] = index
2528
+ if app_name:
2529
+ app_name_indexes[app_name] = index
2377
2530
  if not app_key and not app_name:
2378
- results.append(_multi_app_item_failure(index, item, "APP_SELECTOR_REQUIRED", "apps[] requires app_key or app_name"))
2531
+ results_by_index[index] = _multi_app_item_failure(index, item, "APP_SELECTOR_REQUIRED", "apps[] requires app_key or app_name")
2379
2532
  continue
2380
-
2381
- initial_add_fields, deferred_add_fields = _split_multi_app_initial_add_fields(item, is_new_app=not bool(app_key))
2382
- inline_layout_sections = list(item.get("_inline_form_layout_sections") or [])
2383
- item["_deferred_add_fields"] = deferred_add_fields
2384
- shell = self._app_schema_apply_once(
2385
- profile=profile,
2386
- app_key=app_key,
2387
- package_id=package_id if not app_key else None,
2388
- app_name=app_name,
2389
- app_title="",
2390
- icon=str(item.get("icon") or ""),
2391
- color=str(item.get("color") or ""),
2392
- visibility=item.get("visibility", visibility),
2393
- create_if_missing=create_if_missing and not app_key,
2394
- publish=publish and not deferred_add_fields,
2395
- add_fields=initial_add_fields,
2396
- update_fields=[],
2397
- remove_fields=[],
2398
- inline_form_layout_sections=inline_layout_sections if not deferred_add_fields else None,
2533
+ shell_jobs.append(
2534
+ {
2535
+ "index": index,
2536
+ "item": item,
2537
+ "client_key": client_key,
2538
+ "app_name": app_name,
2539
+ "app_key": app_key,
2540
+ }
2399
2541
  )
2400
- public_shell = _publicize_package_fields(shell)
2542
+
2543
+ shell_workers = worker_count(len(shell_jobs))
2544
+ parallelism["shell_workers"] = shell_workers
2545
+
2546
+ def run_shell_job(job: JSONObject) -> JSONObject:
2547
+ index = int(job["index"])
2548
+ item = deepcopy(job["item"])
2549
+ app_key = str(job.get("app_key") or "")
2550
+ app_name = str(job.get("app_name") or "")
2551
+ shell_started_at = time.perf_counter()
2552
+ try:
2553
+ shell = self._app_schema_apply_once(
2554
+ profile=profile,
2555
+ app_key=app_key,
2556
+ package_id=package_id if not app_key else None,
2557
+ app_name=app_name,
2558
+ app_title="",
2559
+ icon=str(item.get("icon") or ""),
2560
+ color=str(item.get("color") or ""),
2561
+ visibility=item.get("visibility", visibility),
2562
+ publish=False,
2563
+ add_fields=[],
2564
+ update_fields=[],
2565
+ remove_fields=[],
2566
+ inline_form_layout_sections=None,
2567
+ allow_inline_layout_fallback=False,
2568
+ )
2569
+ return {
2570
+ "index": index,
2571
+ "job": job,
2572
+ "elapsed_ms": _elapsed_ms(shell_started_at),
2573
+ "public_shell": _publicize_package_fields(shell),
2574
+ }
2575
+ except Exception as error: # pragma: no cover - defensive guard for unexpected worker failures
2576
+ return {
2577
+ "index": index,
2578
+ "job": job,
2579
+ "elapsed_ms": _elapsed_ms(shell_started_at),
2580
+ "exception": repr(error),
2581
+ }
2582
+
2583
+ shell_outputs: list[JSONObject] = []
2584
+ shell_phase_started_at = time.perf_counter()
2585
+ if shell_jobs:
2586
+ with ThreadPoolExecutor(max_workers=shell_workers) as executor:
2587
+ futures = [executor.submit(run_shell_job, job) for job in shell_jobs]
2588
+ for future in as_completed(futures):
2589
+ shell_outputs.append(future.result())
2590
+ app_shell_apply_ms += _elapsed_ms(shell_phase_started_at)
2591
+
2592
+ for output in sorted(shell_outputs, key=lambda item: int(item.get("index", 0))):
2593
+ index = int(output["index"])
2594
+ job = output.get("job") if isinstance(output.get("job"), dict) else {}
2595
+ client_key = str(job.get("client_key") or "").strip()
2596
+ app_name = str(job.get("app_name") or "").strip()
2597
+ app_key = str(job.get("app_key") or "").strip()
2598
+ item = deepcopy(job.get("item") if isinstance(job.get("item"), dict) else {})
2599
+ exception = output.get("exception")
2600
+ if exception:
2601
+ results_by_index[index] = {
2602
+ "index": index,
2603
+ "row_number": index + 1,
2604
+ "client_key": client_key or None,
2605
+ "app_name": app_name or None,
2606
+ "app_key": app_key or None,
2607
+ "status": "pending_readback",
2608
+ "stage": "resolve_or_create_shell",
2609
+ "error_code": "APP_SHELL_APPLY_EXCEPTION",
2610
+ "message": str(exception),
2611
+ "write_executed": False,
2612
+ "write_may_have_succeeded": True,
2613
+ "safe_to_retry": False,
2614
+ "next_action": "readback_before_retry",
2615
+ }
2616
+ if app_name:
2617
+ pending_readback_app_names.append(app_name)
2618
+ any_write_may_have_succeeded = True
2619
+ continue
2620
+ public_shell = output.get("public_shell") if isinstance(output.get("public_shell"), dict) else {}
2621
+ merge_child_duration(public_shell)
2401
2622
  resolved_key = str(public_shell.get("app_key") or "").strip()
2402
2623
  shell_write_executed = _schema_apply_result_has_write(public_shell)
2403
2624
  shell_write_may_have_succeeded = _schema_apply_result_may_have_write(public_shell)
@@ -2417,7 +2638,7 @@ class AiBuilderTools(ToolBase):
2417
2638
  pending_readback_app_keys.append(resolved_key)
2418
2639
  if app_name:
2419
2640
  pending_readback_app_names.append(app_name)
2420
- results.append({
2641
+ results_by_index[index] = {
2421
2642
  "index": index,
2422
2643
  "row_number": index + 1,
2423
2644
  "client_key": client_key or None,
@@ -2432,8 +2653,8 @@ class AiBuilderTools(ToolBase):
2432
2653
  "safe_to_retry": False if pending_readback else (not shell_write_executed and not any_write_executed),
2433
2654
  **({"next_action": "readback_before_retry"} if pending_readback else {}),
2434
2655
  **({"verification": public_shell.get("verification")} if isinstance(public_shell.get("verification"), dict) else {}),
2435
- **({"created": True} if pending_readback and create_if_missing and not app_key else {}),
2436
- })
2656
+ **({"created": True} if pending_readback and not app_key else {}),
2657
+ }
2437
2658
  continue
2438
2659
  if bool(public_shell.get("created")):
2439
2660
  created_app_keys.append(resolved_key)
@@ -2446,7 +2667,7 @@ class AiBuilderTools(ToolBase):
2446
2667
  resolved_name = str(public_shell.get("app_name_after") or public_shell.get("app_name") or app_name or "").strip()
2447
2668
  if resolved_name:
2448
2669
  app_name_to_app_key[resolved_name] = resolved_key
2449
- results.append({
2670
+ results_by_index[index] = {
2450
2671
  "index": index,
2451
2672
  "row_number": index + 1,
2452
2673
  "client_key": client_key or None,
@@ -2457,105 +2678,218 @@ class AiBuilderTools(ToolBase):
2457
2678
  "shell_result": public_shell,
2458
2679
  "shell_field_diff": public_shell.get("field_diff") or {},
2459
2680
  "shell_field_diff_details": public_shell.get("field_diff_details") or {},
2460
- "deferred_add_fields": deferred_add_fields,
2461
- })
2681
+ }
2462
2682
 
2463
- final_items: list[JSONObject] = []
2683
+ final_items_by_index: list[JSONObject | None] = [None] * len(apps)
2684
+ schema_jobs: list[JSONObject] = []
2464
2685
  for index, raw_item in enumerate(apps):
2465
- existing = next((item for item in results if item.get("index") == index), None)
2686
+ existing = results_by_index[index] if 0 <= index < len(results_by_index) else None
2466
2687
  if not existing or existing.get("status") != "shell_ready":
2467
2688
  if existing:
2468
- final_items.append(existing)
2689
+ final_items_by_index[index] = existing
2469
2690
  continue
2470
2691
  item = deepcopy(raw_item)
2471
2692
  app_key = str(existing.get("app_key") or "").strip()
2693
+ dependency_failure = dependency_failure_for_item(index, item)
2694
+ if dependency_failure is not None:
2695
+ final_items_by_index[index] = dependency_failure
2696
+ any_write_executed = True
2697
+ continue
2698
+ compile_started_at = time.perf_counter()
2472
2699
  try:
2473
2700
  compiled_item = _compile_multi_app_schema_item_refs(item, client_key_to_app_key, app_name_to_app_key)
2474
2701
  except ValueError as error:
2475
- final_items.append({
2702
+ relation_compile_ms += _elapsed_ms(compile_started_at)
2703
+ final_items_by_index[index] = {
2476
2704
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2477
2705
  "status": "failed",
2478
2706
  "stage": "compile_relation_refs",
2479
2707
  "error_code": "TARGET_APP_REF_NOT_FOUND",
2480
2708
  "message": str(error),
2481
2709
  "safe_to_retry": False,
2482
- })
2710
+ }
2483
2711
  any_write_executed = True
2484
2712
  continue
2485
-
2486
- deferred_add_fields = (
2487
- _compiled_multi_app_deferred_add_fields(compiled_item, existing)
2488
- if bool(existing.get("created"))
2489
- else list(compiled_item.get("add_fields") or [])
2490
- )
2713
+ relation_compile_ms += _elapsed_ms(compile_started_at)
2714
+ schema_add_fields = list(compiled_item.get("add_fields") or [])
2491
2715
  update_fields = list(compiled_item.get("update_fields") or [])
2492
2716
  remove_fields = list(compiled_item.get("remove_fields") or [])
2493
- if bool(existing.get("created")) and not deferred_add_fields and not update_fields and not remove_fields:
2494
- shell_result = existing.get("shell_result") if isinstance(existing.get("shell_result"), dict) else {}
2495
- layout_sections = list(compiled_item.get("_inline_form_layout_sections") or [])
2496
- if layout_sections and not _inline_form_layout_was_handled(shell_result):
2497
- shell_result = self._apply_inline_form_layout_after_schema(
2717
+ layout_sections = list(compiled_item.get("_inline_form_layout_sections") or [])
2718
+ field_phase_app_name = ""
2719
+ field_phase_icon = ""
2720
+ field_phase_color = ""
2721
+ field_phase_visibility = None
2722
+ relation_fields_for_retry = [
2723
+ deepcopy(field)
2724
+ for field in schema_add_fields
2725
+ if isinstance(field, dict) and _multi_app_field_type(field) == PublicFieldType.relation.value
2726
+ ]
2727
+ schema_jobs.append(
2728
+ {
2729
+ "index": index,
2730
+ "existing": existing,
2731
+ "app_key": app_key,
2732
+ "field_phase_app_name": field_phase_app_name,
2733
+ "field_phase_icon": field_phase_icon,
2734
+ "field_phase_color": field_phase_color,
2735
+ "field_phase_visibility": field_phase_visibility,
2736
+ "schema_add_fields": schema_add_fields,
2737
+ "update_fields": update_fields,
2738
+ "remove_fields": remove_fields,
2739
+ "layout_sections": layout_sections,
2740
+ "relation_fields_for_retry": relation_fields_for_retry,
2741
+ }
2742
+ )
2743
+
2744
+ schema_workers = worker_count(len(schema_jobs))
2745
+ parallelism["schema_workers"] = schema_workers
2746
+
2747
+ def run_schema_job(job: JSONObject) -> JSONObject:
2748
+ index = int(job["index"])
2749
+ app_key = str(job.get("app_key") or "")
2750
+ field_phase_app_name = str(job.get("field_phase_app_name") or "")
2751
+ field_phase_icon = str(job.get("field_phase_icon") or "")
2752
+ field_phase_color = str(job.get("field_phase_color") or "")
2753
+ field_phase_visibility = job.get("field_phase_visibility")
2754
+ schema_add_fields = list(job.get("schema_add_fields") or [])
2755
+ update_fields = list(job.get("update_fields") or [])
2756
+ remove_fields = list(job.get("remove_fields") or [])
2757
+ layout_sections = list(job.get("layout_sections") or [])
2758
+ relation_fields_for_retry = list(job.get("relation_fields_for_retry") or [])
2759
+ field_started_at = time.perf_counter()
2760
+ try:
2761
+ field_result = self._app_schema_apply_once(
2762
+ profile=profile,
2763
+ app_key=app_key,
2764
+ package_id=None,
2765
+ app_name=field_phase_app_name,
2766
+ app_title="",
2767
+ icon=field_phase_icon,
2768
+ color=field_phase_color,
2769
+ visibility=field_phase_visibility,
2770
+ publish=publish,
2771
+ add_fields=schema_add_fields,
2772
+ update_fields=update_fields,
2773
+ remove_fields=remove_fields,
2774
+ inline_form_layout_sections=layout_sections,
2775
+ allow_inline_layout_fallback=False,
2776
+ )
2777
+ public_result = _publicize_package_fields(field_result)
2778
+ except Exception as error: # pragma: no cover - defensive guard for unexpected worker failures
2779
+ return {
2780
+ "index": index,
2781
+ "job": job,
2782
+ "elapsed_ms": _elapsed_ms(field_started_at),
2783
+ "exception": repr(error),
2784
+ }
2785
+ if _multi_app_deferred_relation_retryable(public_result, relation_fields_for_retry):
2786
+ first_error = {
2787
+ "error_code": public_result.get("error_code"),
2788
+ "message": public_result.get("message"),
2789
+ "backend_code": public_result.get("backend_code"),
2790
+ "http_status": public_result.get("http_status"),
2791
+ "details": deepcopy(public_result.get("details")) if isinstance(public_result.get("details"), dict) else None,
2792
+ }
2793
+ retry_attempts: list[JSONObject] = []
2794
+ for retry_index, delay_seconds in enumerate((1.0, 5.0), start=1):
2795
+ retry_readback = self._stabilize_deferred_relation_metadata(
2498
2796
  profile=profile,
2499
- schema_result=shell_result,
2500
- fallback_app_key=app_key,
2797
+ source_app_key=app_key,
2798
+ deferred_add_fields=relation_fields_for_retry,
2799
+ delay_seconds=delay_seconds,
2800
+ )
2801
+ retry_result = self._app_schema_apply_once(
2802
+ profile=profile,
2803
+ app_key=app_key,
2804
+ package_id=None,
2805
+ app_name=field_phase_app_name,
2806
+ app_title="",
2807
+ icon=field_phase_icon,
2808
+ color=field_phase_color,
2809
+ visibility=field_phase_visibility,
2501
2810
  publish=publish,
2502
- sections=layout_sections,
2811
+ add_fields=schema_add_fields,
2812
+ update_fields=update_fields,
2813
+ remove_fields=remove_fields,
2814
+ inline_form_layout_sections=layout_sections,
2815
+ allow_inline_layout_fallback=False,
2503
2816
  )
2504
- if _schema_apply_result_has_write(shell_result):
2505
- any_write_executed = True
2506
- if _schema_apply_result_may_have_write(shell_result):
2507
- any_write_may_have_succeeded = True
2508
- item_status = shell_result.get("status") if shell_result.get("status") in {"success", "partial_success"} else "failed"
2509
- shell_field_diff = existing.get("shell_field_diff") if isinstance(existing.get("shell_field_diff"), dict) else {}
2510
- shell_field_diff_details = existing.get("shell_field_diff_details") if isinstance(existing.get("shell_field_diff_details"), dict) else {}
2511
- final_items.append({
2817
+ public_retry_result = _publicize_package_fields(retry_result)
2818
+ retry_attempts.append(
2819
+ {
2820
+ "attempt": retry_index,
2821
+ "delay_seconds": delay_seconds,
2822
+ "status": public_retry_result.get("status"),
2823
+ "error_code": public_retry_result.get("error_code"),
2824
+ "backend_code": public_retry_result.get("backend_code"),
2825
+ "http_status": public_retry_result.get("http_status"),
2826
+ "readback": retry_readback,
2827
+ }
2828
+ )
2829
+ public_result = public_retry_result
2830
+ if not _multi_app_deferred_relation_retryable(public_result, relation_fields_for_retry):
2831
+ break
2832
+ retry_details = public_result.get("details")
2833
+ if not isinstance(retry_details, dict):
2834
+ retry_details = {}
2835
+ public_result["details"] = retry_details
2836
+ retry_summary: JSONObject = {
2837
+ "attempted": True,
2838
+ "reason": "same_batch_relation_metadata_may_lag",
2839
+ "first_error": first_error,
2840
+ "attempts": retry_attempts,
2841
+ }
2842
+ if retry_attempts:
2843
+ retry_summary["readback"] = retry_attempts[-1].get("readback")
2844
+ retry_details["deferred_relation_retry"] = retry_summary
2845
+ if layout_sections and not _inline_form_layout_was_handled(public_result):
2846
+ public_result = mark_inline_layout_not_applied_in_schema(
2847
+ public_result,
2848
+ app_key=app_key,
2849
+ sections=layout_sections,
2850
+ )
2851
+ return {
2852
+ "index": index,
2853
+ "job": job,
2854
+ "elapsed_ms": _elapsed_ms(field_started_at),
2855
+ "public_result": public_result,
2856
+ }
2857
+
2858
+ schema_outputs: list[JSONObject] = []
2859
+ schema_phase_started_at = time.perf_counter()
2860
+ if schema_jobs:
2861
+ with ThreadPoolExecutor(max_workers=schema_workers) as executor:
2862
+ futures = [executor.submit(run_schema_job, job) for job in schema_jobs]
2863
+ for future in as_completed(futures):
2864
+ schema_outputs.append(future.result())
2865
+ app_field_apply_ms += _elapsed_ms(schema_phase_started_at)
2866
+
2867
+ for output in sorted(schema_outputs, key=lambda item: int(item.get("index", 0))):
2868
+ index = int(output["index"])
2869
+ job = output.get("job") if isinstance(output.get("job"), dict) else {}
2870
+ existing = job.get("existing") if isinstance(job.get("existing"), dict) else {}
2871
+ app_key = str(job.get("app_key") or "")
2872
+ exception = output.get("exception")
2873
+ if exception:
2874
+ final_items_by_index[index] = {
2512
2875
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2513
- "status": item_status,
2876
+ "status": "pending_readback",
2514
2877
  "stage": "schema_apply",
2515
- "field_diff": shell_field_diff,
2516
- "field_diff_details": shell_field_diff_details,
2517
- "shell_field_diff": shell_field_diff,
2518
- "shell_field_diff_details": shell_field_diff_details,
2519
- "published": bool(shell_result.get("published")),
2520
- "verified": bool(shell_result.get("verified")),
2521
- "error_code": shell_result.get("error_code"),
2522
- "message": shell_result.get("message"),
2523
- "write_executed": _schema_apply_result_has_write(shell_result),
2524
- "write_may_have_succeeded": _schema_apply_result_may_have_write(shell_result),
2878
+ "error_code": "SCHEMA_APPLY_EXCEPTION",
2879
+ "message": str(exception),
2880
+ "write_executed": False,
2881
+ "write_may_have_succeeded": True,
2525
2882
  "safe_to_retry": False,
2526
- **({"next_action": shell_result.get("next_action")} if shell_result.get("next_action") else {}),
2527
- **({"verification": shell_result.get("verification")} if isinstance(shell_result.get("verification"), dict) else {}),
2528
- **({"inline_form_layout": shell_result.get("inline_form_layout")} if isinstance(shell_result.get("inline_form_layout"), dict) else {}),
2529
- **({"inline_form_layout_result": shell_result.get("inline_form_layout_result")} if isinstance(shell_result.get("inline_form_layout_result"), dict) else {}),
2530
- })
2883
+ "next_action": "readback_before_retry",
2884
+ }
2885
+ if app_key:
2886
+ pending_readback_app_keys.append(app_key)
2887
+ if existing.get("app_name"):
2888
+ pending_readback_app_names.append(str(existing.get("app_name")))
2889
+ any_write_may_have_succeeded = True
2531
2890
  continue
2532
-
2533
- field_result = self._app_schema_apply_once(
2534
- profile=profile,
2535
- app_key=app_key,
2536
- package_id=None,
2537
- app_name=str(compiled_item.get("app_name") or compiled_item.get("appTitle") or compiled_item.get("app_title") or ""),
2538
- app_title="",
2539
- icon=str(compiled_item.get("icon") or ""),
2540
- color=str(compiled_item.get("color") or ""),
2541
- visibility=compiled_item.get("visibility"),
2542
- create_if_missing=False,
2543
- publish=publish,
2544
- add_fields=deferred_add_fields,
2545
- update_fields=update_fields,
2546
- remove_fields=remove_fields,
2547
- inline_form_layout_sections=list(compiled_item.get("_inline_form_layout_sections") or []),
2548
- )
2549
- public_result = _publicize_package_fields(field_result)
2550
- layout_sections = list(compiled_item.get("_inline_form_layout_sections") or [])
2551
- if layout_sections and not _inline_form_layout_was_handled(public_result):
2552
- public_result = self._apply_inline_form_layout_after_schema(
2553
- profile=profile,
2554
- schema_result=public_result,
2555
- fallback_app_key=app_key,
2556
- publish=publish,
2557
- sections=layout_sections,
2558
- )
2891
+ public_result = output.get("public_result") if isinstance(output.get("public_result"), dict) else {}
2892
+ merge_child_duration(public_result)
2559
2893
  if _schema_apply_result_has_write(public_result):
2560
2894
  any_write_executed = True
2561
2895
  if _schema_apply_result_may_have_write(public_result):
@@ -2570,7 +2904,7 @@ class AiBuilderTools(ToolBase):
2570
2904
  pending_readback_app_keys.append(app_key)
2571
2905
  if existing.get("app_name"):
2572
2906
  pending_readback_app_names.append(str(existing.get("app_name")))
2573
- final_items.append({
2907
+ final_items_by_index[index] = {
2574
2908
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2575
2909
  "status": item_status,
2576
2910
  "stage": "schema_apply",
@@ -2585,12 +2919,17 @@ class AiBuilderTools(ToolBase):
2585
2919
  "write_executed": _schema_apply_result_has_write(public_result),
2586
2920
  "write_may_have_succeeded": _schema_apply_result_may_have_write(public_result),
2587
2921
  "safe_to_retry": False,
2922
+ **_multi_app_child_diagnostics(public_result),
2588
2923
  **({"next_action": public_result.get("next_action")} if public_result.get("next_action") else {}),
2589
2924
  **({"verification": public_result.get("verification")} if isinstance(public_result.get("verification"), dict) else {}),
2590
2925
  **({"inline_form_layout": public_result.get("inline_form_layout")} if isinstance(public_result.get("inline_form_layout"), dict) else {}),
2591
2926
  **({"inline_form_layout_result": public_result.get("inline_form_layout_result")} if isinstance(public_result.get("inline_form_layout_result"), dict) else {}),
2592
- })
2927
+ }
2593
2928
 
2929
+ final_items: list[JSONObject] = [
2930
+ item if isinstance(item, dict) else _multi_app_item_failure(index, apps[index], "APP_ITEM_NOT_PROCESSED", "app item was not processed")
2931
+ for index, item in enumerate(final_items_by_index)
2932
+ ]
2594
2933
  pending_readback = sum(1 for item in final_items if item.get("status") == "pending_readback")
2595
2934
  succeeded = sum(1 for item in final_items if item.get("status") in {"success", "partial_success"})
2596
2935
  failed = sum(1 for item in final_items if item.get("status") == "failed")
@@ -2614,6 +2953,7 @@ class AiBuilderTools(ToolBase):
2614
2953
  "package_id": package_id,
2615
2954
  "publish_requested": publish,
2616
2955
  "apps": final_items,
2956
+ "parallelism": parallelism,
2617
2957
  "normalized_args": normalized_args,
2618
2958
  "verification": {
2619
2959
  "all_apps_succeeded": failed == 0,
@@ -2634,7 +2974,69 @@ class AiBuilderTools(ToolBase):
2634
2974
  response["next_action"] = "readback_before_retry"
2635
2975
  response["pending_readback_app_keys"] = list(dict.fromkeys(pending_readback_app_keys))
2636
2976
  response["pending_readback_app_names"] = list(dict.fromkeys(pending_readback_app_names))
2637
- return response
2977
+ return finish(response)
2978
+
2979
+ def _stabilize_deferred_relation_metadata(
2980
+ self,
2981
+ *,
2982
+ profile: str,
2983
+ source_app_key: str,
2984
+ deferred_add_fields: list[JSONObject],
2985
+ delay_seconds: float,
2986
+ ) -> list[JSONObject]:
2987
+ edit_context_result = self._finish_deferred_relation_edit_context(profile=profile, app_key=source_app_key)
2988
+ time.sleep(delay_seconds)
2989
+ app_keys = [source_app_key]
2990
+ for key in _multi_app_relation_target_app_keys(deferred_add_fields):
2991
+ if key and key not in app_keys:
2992
+ app_keys.append(key)
2993
+ readback: list[JSONObject] = []
2994
+ for app_key in app_keys:
2995
+ try:
2996
+ result = self._facade.app_read_fields(profile=profile, app_key=app_key)
2997
+ readback.append(
2998
+ {
2999
+ "app_key": app_key,
3000
+ "status": result.get("status"),
3001
+ "field_count": len(result.get("fields") or []),
3002
+ **({"edit_context": edit_context_result} if app_key == source_app_key else {}),
3003
+ }
3004
+ )
3005
+ except (QingflowApiError, RuntimeError) as error:
3006
+ api_error = _coerce_api_error(error)
3007
+ readback.append(
3008
+ {
3009
+ "app_key": app_key,
3010
+ "status": "failed",
3011
+ "transport_error": {
3012
+ "http_status": api_error.http_status,
3013
+ "backend_code": api_error.backend_code,
3014
+ "category": api_error.category,
3015
+ "request_id": api_error.request_id,
3016
+ },
3017
+ }
3018
+ )
3019
+ return readback
3020
+
3021
+ def _finish_deferred_relation_edit_context(self, *, profile: str, app_key: str) -> JSONObject:
3022
+ try:
3023
+ version_result = self._facade.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
3024
+ raw_version = version_result.get("editVersionNo") or version_result.get("versionNo") or 1
3025
+ edit_version_no = int(raw_version)
3026
+ self._facade.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
3027
+ return {"status": "success", "edit_version_no": edit_version_no}
3028
+ except (QingflowApiError, RuntimeError, TypeError, ValueError) as error:
3029
+ api_error = _coerce_api_error(error if isinstance(error, (QingflowApiError, RuntimeError)) else RuntimeError(str(error)))
3030
+ return {
3031
+ "status": "failed",
3032
+ "message": api_error.message,
3033
+ "transport_error": {
3034
+ "http_status": api_error.http_status,
3035
+ "backend_code": api_error.backend_code,
3036
+ "category": api_error.category,
3037
+ "request_id": api_error.request_id,
3038
+ },
3039
+ }
2638
3040
 
2639
3041
  def _app_schema_apply_once(
2640
3042
  self,
@@ -2647,16 +3049,17 @@ class AiBuilderTools(ToolBase):
2647
3049
  icon: str = "",
2648
3050
  color: str = "",
2649
3051
  visibility: JSONObject | None = None,
2650
- create_if_missing: bool = False,
2651
3052
  publish: bool = True,
2652
3053
  add_fields: list[JSONObject],
2653
3054
  update_fields: list[JSONObject],
2654
3055
  remove_fields: list[JSONObject],
2655
3056
  inline_form_layout_sections: list[JSONObject] | None = None,
3057
+ allow_inline_layout_fallback: bool = True,
2656
3058
  ) -> JSONObject:
2657
3059
  """执行内部辅助逻辑。"""
2658
3060
  started_at = time.perf_counter()
2659
3061
  effective_app_name = app_name or app_title
3062
+ creating = not bool(str(app_key or "").strip()) and package_id is not None and bool(str(effective_app_name or "").strip())
2660
3063
  system_field_failure = _reserved_system_field_name_failure(
2661
3064
  tool_name="app_schema_apply",
2662
3065
  profile=profile,
@@ -2666,7 +3069,6 @@ class AiBuilderTools(ToolBase):
2666
3069
  icon=icon,
2667
3070
  color=color,
2668
3071
  visibility=visibility,
2669
- create_if_missing=create_if_missing,
2670
3072
  publish=publish,
2671
3073
  add_fields=add_fields,
2672
3074
  update_fields=update_fields,
@@ -2678,42 +3080,19 @@ class AiBuilderTools(ToolBase):
2678
3080
  tool_name="app_schema_apply",
2679
3081
  icon=icon,
2680
3082
  color=color,
2681
- creating=not bool(str(app_key or "").strip()) and bool(create_if_missing),
3083
+ creating=creating,
2682
3084
  )
2683
3085
  if icon_failure is not None:
2684
3086
  return icon_failure
2685
- plan_result = self._rewrite_plan_result_for_apply(
2686
- result=self.app_schema_plan(
2687
- profile=profile,
2688
- app_key=app_key,
2689
- package_id=package_id,
2690
- app_name=effective_app_name,
2691
- icon=icon,
2692
- color=color,
2693
- visibility=visibility,
2694
- create_if_missing=create_if_missing,
2695
- add_fields=add_fields,
2696
- update_fields=update_fields,
2697
- remove_fields=remove_fields,
2698
- ),
2699
- profile=profile,
2700
- publish=publish,
2701
- plan_tool_name="app_schema_plan",
2702
- apply_tool_name="app_schema_apply",
2703
- )
2704
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
2705
- return plan_result
2706
- plan_args = plan_result.get("normalized_args")
2707
- if not isinstance(plan_args, dict):
2708
- plan_args = {}
2709
3087
  try:
2710
- parsed_add = [FieldPatch.model_validate(item) for item in plan_args.get("add_fields") or []]
2711
- parsed_update = [FieldUpdatePatch.model_validate(item) for item in plan_args.get("update_fields") or []]
2712
- parsed_remove = [FieldRemovePatch.model_validate(item) for item in plan_args.get("remove_fields") or []]
3088
+ parsed_add = [FieldPatch.model_validate(item) for item in add_fields or []]
3089
+ parsed_update = [FieldUpdatePatch.model_validate(item) for item in update_fields or []]
3090
+ parsed_remove = [FieldRemovePatch.model_validate(item) for item in remove_fields or []]
2713
3091
  parsed_inline_layout = [
2714
3092
  LayoutSectionPatch.model_validate(item)
2715
3093
  for item in (inline_form_layout_sections or [])
2716
3094
  ]
3095
+ parsed_visibility = VisibilityPatch.model_validate(visibility) if visibility is not None else None
2717
3096
  except ValidationError as exc:
2718
3097
  return _validation_failure(
2719
3098
  str(exc),
@@ -2723,28 +3102,26 @@ class AiBuilderTools(ToolBase):
2723
3102
  "tool_name": "app_schema_apply",
2724
3103
  "arguments": {
2725
3104
  "profile": profile,
2726
- "app_key": str(plan_args.get("app_key") or app_key),
2727
- "package_id": plan_args.get("package_id", package_id),
2728
- "app_name": str(plan_args.get("app_name") or effective_app_name),
2729
- "icon": str(plan_args.get("icon") or icon),
2730
- "color": str(plan_args.get("color") or color),
2731
- "visibility": plan_args.get("visibility") or visibility,
2732
- "create_if_missing": bool(plan_args.get("create_if_missing", create_if_missing)),
3105
+ "app_key": str(app_key or ""),
3106
+ "package_id": package_id,
3107
+ "app_name": effective_app_name,
3108
+ "icon": str(icon or ""),
3109
+ "color": str(color or ""),
3110
+ "visibility": visibility,
2733
3111
  "publish": publish,
2734
- "add_fields": plan_args.get("add_fields") or [{"name": "字段名称", "type": "text", "required": False}],
2735
- "update_fields": plan_args.get("update_fields") or [],
2736
- "remove_fields": plan_args.get("remove_fields") or [],
3112
+ "add_fields": add_fields or [{"name": "字段名称", "type": "text", "required": False}],
3113
+ "update_fields": update_fields or [],
3114
+ "remove_fields": remove_fields or [],
2737
3115
  },
2738
3116
  },
2739
3117
  )
2740
3118
  normalized_args = {
2741
- "app_key": str(plan_args.get("app_key") or app_key),
2742
- "package_id": plan_args.get("package_id", package_id),
2743
- "app_name": str(plan_args.get("app_name") or effective_app_name),
2744
- "icon": str(plan_args.get("icon") or icon or ""),
2745
- "color": str(plan_args.get("color") or color or ""),
2746
- "visibility": plan_args.get("visibility") or visibility,
2747
- "create_if_missing": bool(plan_args.get("create_if_missing", create_if_missing)),
3119
+ "app_key": str(app_key or ""),
3120
+ "package_id": package_id,
3121
+ "app_name": effective_app_name,
3122
+ "icon": str(icon or ""),
3123
+ "color": str(color or ""),
3124
+ "visibility": parsed_visibility.model_dump(mode="json") if parsed_visibility is not None else None,
2748
3125
  "publish": publish,
2749
3126
  "add_fields": [patch.model_dump(mode="json") for patch in parsed_add],
2750
3127
  "update_fields": [patch.model_dump(mode="json") for patch in parsed_update],
@@ -2763,13 +3140,12 @@ class AiBuilderTools(ToolBase):
2763
3140
  result = _safe_tool_call(
2764
3141
  lambda: self._facade.app_schema_apply(
2765
3142
  profile=profile,
2766
- app_key=str(plan_args.get("app_key") or app_key),
2767
- package_tag_id=plan_args.get("package_id", package_id),
2768
- app_name=str(plan_args.get("app_name") or effective_app_name),
2769
- icon=str(plan_args.get("icon") or icon or ""),
2770
- color=str(plan_args.get("color") or color or ""),
2771
- visibility=VisibilityPatch.model_validate(plan_args.get("visibility")) if plan_args.get("visibility") is not None else None,
2772
- create_if_missing=bool(plan_args.get("create_if_missing", create_if_missing)),
3143
+ app_key=str(app_key or ""),
3144
+ package_tag_id=package_id,
3145
+ app_name=effective_app_name,
3146
+ icon=str(icon or ""),
3147
+ color=str(color or ""),
3148
+ visibility=parsed_visibility,
2773
3149
  publish=publish,
2774
3150
  add_fields=parsed_add,
2775
3151
  update_fields=parsed_update,
@@ -2781,12 +3157,12 @@ class AiBuilderTools(ToolBase):
2781
3157
  suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
2782
3158
  )
2783
3159
  public_result = _publicize_package_fields(result)
2784
- if inline_form_layout_sections and not _inline_form_layout_was_handled(public_result):
3160
+ if inline_form_layout_sections and allow_inline_layout_fallback and not _inline_form_layout_was_handled(public_result):
2785
3161
  layout_started_at = time.perf_counter()
2786
3162
  public_result = self._apply_inline_form_layout_after_schema(
2787
3163
  profile=profile,
2788
3164
  schema_result=public_result,
2789
- fallback_app_key=str(plan_args.get("app_key") or app_key),
3165
+ fallback_app_key=str(app_key or ""),
2790
3166
  publish=publish,
2791
3167
  sections=inline_form_layout_sections,
2792
3168
  )
@@ -2912,27 +3288,9 @@ class AiBuilderTools(ToolBase):
2912
3288
 
2913
3289
  def _app_layout_apply_once(self, *, profile: str, app_key: str, mode: str = "merge", publish: bool = True, sections: list[JSONObject]) -> JSONObject:
2914
3290
  """执行内部辅助逻辑。"""
2915
- plan_result = self._rewrite_plan_result_for_apply(
2916
- result=self.app_layout_plan(
2917
- profile=profile,
2918
- app_key=app_key,
2919
- mode=mode,
2920
- sections=sections,
2921
- preset=None,
2922
- ),
2923
- profile=profile,
2924
- publish=publish,
2925
- plan_tool_name="app_layout_plan",
2926
- apply_tool_name="app_layout_apply",
2927
- )
2928
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
2929
- return plan_result
2930
- plan_args = plan_result.get("normalized_args")
2931
- if not isinstance(plan_args, dict):
2932
- plan_args = {}
2933
3291
  try:
2934
- parsed_mode = LayoutApplyMode(str(plan_args.get("mode") or mode))
2935
- parsed_sections = [LayoutSectionPatch.model_validate(item) for item in plan_args.get("sections") or []]
3292
+ parsed_mode = LayoutApplyMode(str(mode or "merge"))
3293
+ parsed_sections = [LayoutSectionPatch.model_validate(item) for item in sections or []]
2936
3294
  except (ValueError, ValidationError) as exc:
2937
3295
  return _validation_failure(
2938
3296
  str(exc),
@@ -2942,15 +3300,15 @@ class AiBuilderTools(ToolBase):
2942
3300
  "tool_name": "app_layout_apply",
2943
3301
  "arguments": {
2944
3302
  "profile": profile,
2945
- "app_key": str(plan_args.get("app_key") or app_key),
2946
- "mode": str(plan_args.get("mode") or "merge"),
3303
+ "app_key": app_key,
3304
+ "mode": str(mode or "merge"),
2947
3305
  "publish": publish,
2948
- "sections": plan_args.get("sections") or [{"title": "基础信息", "rows": [["字段A", "字段B"]]}],
3306
+ "sections": sections or [{"title": "基础信息", "rows": [["字段A", "字段B"]]}],
2949
3307
  },
2950
3308
  },
2951
3309
  )
2952
3310
  normalized_args = {
2953
- "app_key": str(plan_args.get("app_key") or app_key),
3311
+ "app_key": app_key,
2954
3312
  "mode": parsed_mode.value,
2955
3313
  "publish": publish,
2956
3314
  "sections": [section.model_dump(mode="json", exclude_none=True) for section in parsed_sections],
@@ -2958,7 +3316,7 @@ class AiBuilderTools(ToolBase):
2958
3316
  return _safe_tool_call(
2959
3317
  lambda: self._facade.app_layout_apply(
2960
3318
  profile=profile,
2961
- app_key=str(plan_args.get("app_key") or app_key),
3319
+ app_key=app_key,
2962
3320
  mode=parsed_mode,
2963
3321
  publish=publish,
2964
3322
  sections=parsed_sections,
@@ -3061,32 +3419,13 @@ class AiBuilderTools(ToolBase):
3061
3419
  transitions: list[JSONObject],
3062
3420
  ) -> JSONObject:
3063
3421
  """执行内部辅助逻辑。"""
3064
- plan_result = self._rewrite_plan_result_for_apply(
3065
- result=self.app_flow_plan(
3066
- profile=profile,
3067
- app_key=app_key,
3068
- mode=mode,
3069
- nodes=nodes,
3070
- transitions=transitions,
3071
- preset=None,
3072
- ),
3073
- profile=profile,
3074
- publish=publish,
3075
- plan_tool_name="app_flow_plan",
3076
- apply_tool_name="app_flow_apply",
3077
- )
3078
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
3079
- return plan_result
3080
- plan_args = plan_result.get("normalized_args")
3081
- if not isinstance(plan_args, dict):
3082
- plan_args = {}
3083
3422
  try:
3084
3423
  request = FlowPlanRequest.model_validate(
3085
3424
  {
3086
- "app_key": plan_args.get("app_key") or app_key,
3087
- "mode": plan_args.get("mode") or mode,
3088
- "nodes": plan_args.get("nodes") or [],
3089
- "transitions": plan_args.get("transitions") or [],
3425
+ "app_key": app_key,
3426
+ "mode": mode,
3427
+ "nodes": nodes or [],
3428
+ "transitions": transitions or [],
3090
3429
  "preset": None,
3091
3430
  }
3092
3431
  )
@@ -3099,11 +3438,11 @@ class AiBuilderTools(ToolBase):
3099
3438
  "tool_name": "app_flow_apply",
3100
3439
  "arguments": {
3101
3440
  "profile": profile,
3102
- "app_key": str(plan_args.get("app_key") or app_key),
3103
- "mode": str(plan_args.get("mode") or "replace"),
3441
+ "app_key": app_key,
3442
+ "mode": str(mode or "replace"),
3104
3443
  "publish": publish,
3105
- "nodes": plan_args.get("nodes") or [{"id": "start", "type": "start", "name": "发起"}],
3106
- "transitions": plan_args.get("transitions") or [],
3444
+ "nodes": nodes or [{"id": "start", "type": "start", "name": "发起"}],
3445
+ "transitions": transitions or [],
3107
3446
  },
3108
3447
  },
3109
3448
  )
@@ -3200,69 +3539,9 @@ class AiBuilderTools(ToolBase):
3200
3539
  )
3201
3540
  if reserved_failure is not None:
3202
3541
  return reserved_failure
3203
- if patch_views or _view_payload_has_action_buttons(upsert_views=upsert_views, patch_views=patch_views):
3204
- try:
3205
- parsed_views = [ViewUpsertPatch.model_validate(item) for item in (upsert_views or [])]
3206
- parsed_patch_views = [ViewPartialPatch.model_validate(item) for item in patch_views]
3207
- except ValidationError as exc:
3208
- return _visibility_validation_failure(
3209
- str(exc),
3210
- tool_name="app_views_apply",
3211
- exc=exc,
3212
- suggested_next_call={
3213
- "tool_name": "app_views_apply",
3214
- "arguments": {
3215
- "profile": profile,
3216
- "app_key": app_key,
3217
- "publish": publish,
3218
- "upsert_views": upsert_views or [],
3219
- "patch_views": [
3220
- {"view_key": "VIEW_KEY", "set": {"query_conditions": {"enabled": True, "rows": [["字段A"]]}}},
3221
- ],
3222
- "remove_views": remove_views or [],
3223
- },
3224
- },
3225
- )
3226
- normalized_args = {
3227
- "app_key": app_key,
3228
- "publish": publish,
3229
- "upsert_views": [public_view_upsert_payload(view) for view in parsed_views],
3230
- "patch_views": [public_view_partial_payload(patch) for patch in parsed_patch_views],
3231
- "remove_views": list(remove_views or []),
3232
- }
3233
- return _safe_tool_call(
3234
- lambda: self._facade.app_views_apply(
3235
- profile=profile,
3236
- app_key=app_key,
3237
- publish=publish,
3238
- upsert_views=parsed_views,
3239
- patch_views=parsed_patch_views,
3240
- remove_views=list(remove_views or []),
3241
- ),
3242
- error_code="VIEWS_APPLY_FAILED",
3243
- normalized_args=normalized_args,
3244
- suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **normalized_args}},
3245
- )
3246
- plan_result = self._rewrite_plan_result_for_apply(
3247
- result=self.app_views_plan(
3248
- profile=profile,
3249
- app_key=app_key,
3250
- upsert_views=upsert_views,
3251
- remove_views=remove_views,
3252
- preset=None,
3253
- ),
3254
- profile=profile,
3255
- publish=publish,
3256
- plan_tool_name="app_views_plan",
3257
- apply_tool_name="app_views_apply",
3258
- )
3259
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
3260
- return plan_result
3261
- plan_args = plan_result.get("normalized_args")
3262
- if not isinstance(plan_args, dict):
3263
- plan_args = {}
3264
3542
  try:
3265
- parsed_views = [ViewUpsertPatch.model_validate(item) for item in plan_args.get("upsert_views") or []]
3543
+ parsed_views = [ViewUpsertPatch.model_validate(item) for item in (upsert_views or [])]
3544
+ parsed_patch_views = [ViewPartialPatch.model_validate(item) for item in (patch_views or [])]
3266
3545
  except ValidationError as exc:
3267
3546
  return _visibility_validation_failure(
3268
3547
  str(exc),
@@ -3272,28 +3551,29 @@ class AiBuilderTools(ToolBase):
3272
3551
  "tool_name": "app_views_apply",
3273
3552
  "arguments": {
3274
3553
  "profile": profile,
3275
- "app_key": str(plan_args.get("app_key") or app_key),
3554
+ "app_key": app_key,
3276
3555
  "publish": publish,
3277
- "upsert_views": plan_args.get("upsert_views") or [{"name": "业务台账视图", "type": "table", "columns": ["字段A"]}],
3278
- "remove_views": plan_args.get("remove_views") or [],
3556
+ "upsert_views": upsert_views or [{"name": "业务台账视图", "type": "table", "columns": ["字段A"]}],
3557
+ "patch_views": patch_views or [],
3558
+ "remove_views": remove_views or [],
3279
3559
  },
3280
3560
  },
3281
3561
  )
3282
3562
  normalized_args = {
3283
- "app_key": str(plan_args.get("app_key") or app_key),
3563
+ "app_key": app_key,
3284
3564
  "publish": publish,
3285
3565
  "upsert_views": [public_view_upsert_payload(view) for view in parsed_views],
3286
- "patch_views": [],
3287
- "remove_views": list(plan_args.get("remove_views") or remove_views),
3566
+ "patch_views": [public_view_partial_payload(patch) for patch in parsed_patch_views],
3567
+ "remove_views": list(remove_views or []),
3288
3568
  }
3289
3569
  return _safe_tool_call(
3290
3570
  lambda: self._facade.app_views_apply(
3291
3571
  profile=profile,
3292
- app_key=str(plan_args.get("app_key") or app_key),
3572
+ app_key=app_key,
3293
3573
  publish=publish,
3294
3574
  upsert_views=parsed_views,
3295
- patch_views=[],
3296
- remove_views=list(plan_args.get("remove_views") or remove_views),
3575
+ patch_views=parsed_patch_views,
3576
+ remove_views=list(remove_views or []),
3297
3577
  ),
3298
3578
  error_code="VIEWS_APPLY_FAILED",
3299
3579
  normalized_args=normalized_args,
@@ -4049,7 +4329,6 @@ def _multi_app_static_validation_failure(
4049
4329
  *,
4050
4330
  tool_name: str,
4051
4331
  apps: list[JSONObject],
4052
- create_if_missing: bool,
4053
4332
  ) -> JSONObject | None:
4054
4333
  issues: list[JSONObject] = []
4055
4334
  client_key_indexes: dict[str, int] = {}
@@ -4112,17 +4391,6 @@ def _multi_app_static_validation_failure(
4112
4391
  }
4113
4392
  )
4114
4393
  continue
4115
- if new_app_item and not create_if_missing:
4116
- issues.append(
4117
- {
4118
- "index": index,
4119
- "row_number": index + 1,
4120
- "path": f"apps[{index}]",
4121
- "error_code": "CREATE_IF_MISSING_REQUIRED",
4122
- "message": "apps[] item with app_name but no app_key cannot run when create_if_missing=false",
4123
- "fix_hint": "Omit create_if_missing in multi-app mode, set it to true, or pass app_key for every existing app item.",
4124
- }
4125
- )
4126
4394
 
4127
4395
  add_fields = _multi_app_list_value(item, "add_fields", "addFields")
4128
4396
  if new_app_item:
@@ -4324,6 +4592,86 @@ def _compiled_multi_app_deferred_add_fields(compiled_item: JSONObject, existing_
4324
4592
  ]
4325
4593
 
4326
4594
 
4595
+ def _multi_app_child_diagnostics(result: JSONObject) -> JSONObject:
4596
+ diagnostics: JSONObject = {}
4597
+ for key in (
4598
+ "backend_code",
4599
+ "http_status",
4600
+ "request_id",
4601
+ "details",
4602
+ "suggested_next_call",
4603
+ "warnings",
4604
+ "publish_result",
4605
+ "duration_breakdown_ms",
4606
+ ):
4607
+ value = result.get(key)
4608
+ if value is None:
4609
+ continue
4610
+ if isinstance(value, (dict, list)) and not value:
4611
+ continue
4612
+ diagnostics[key] = deepcopy(value)
4613
+ return diagnostics
4614
+
4615
+
4616
+ def _multi_app_field_type(field: JSONObject) -> str:
4617
+ raw_type = str(field.get("type") or field.get("field_type") or field.get("fieldType") or "").strip()
4618
+ if raw_type:
4619
+ normalized = FIELD_TYPE_ALIASES.get(raw_type.lower())
4620
+ return normalized.value if normalized is not None else raw_type
4621
+ raw_type_id = field.get("type_id") or field.get("typeId") or field.get("field_type_id") or field.get("fieldTypeId")
4622
+ try:
4623
+ normalized_by_id = FIELD_TYPE_ID_ALIASES.get(int(raw_type_id))
4624
+ except (TypeError, ValueError):
4625
+ normalized_by_id = None
4626
+ return normalized_by_id.value if normalized_by_id is not None else ""
4627
+
4628
+
4629
+ def _multi_app_deferred_relation_retryable(result: JSONObject, deferred_add_fields: list[JSONObject]) -> bool:
4630
+ if not deferred_add_fields:
4631
+ return False
4632
+ if not any(_multi_app_field_type(field) == PublicFieldType.relation.value for field in deferred_add_fields if isinstance(field, dict)):
4633
+ return False
4634
+ if result.get("status") in {"success", "partial_success"}:
4635
+ return False
4636
+ if result.get("error_code") != "SCHEMA_APPLY_FAILED":
4637
+ return False
4638
+ if _schema_apply_result_has_write(result):
4639
+ return False
4640
+ return backend_code_value_int(_result_backend_code(result)) == 400
4641
+
4642
+
4643
+ def _result_backend_code(result: JSONObject) -> Any:
4644
+ if result.get("backend_code") is not None:
4645
+ return result.get("backend_code")
4646
+ details = result.get("details")
4647
+ if isinstance(details, dict):
4648
+ transport = details.get("transport_error")
4649
+ if isinstance(transport, dict):
4650
+ return transport.get("backend_code")
4651
+ return None
4652
+
4653
+
4654
+ def _multi_app_relation_target_app_keys(fields: list[JSONObject]) -> list[str]:
4655
+ app_keys: list[str] = []
4656
+
4657
+ def visit(value: Any) -> None:
4658
+ if isinstance(value, list):
4659
+ for item in value:
4660
+ visit(item)
4661
+ return
4662
+ if not isinstance(value, dict):
4663
+ return
4664
+ target_app_key = str(value.get("target_app_key") or value.get("targetAppKey") or "").strip()
4665
+ if target_app_key and target_app_key not in app_keys:
4666
+ app_keys.append(target_app_key)
4667
+ for child in value.values():
4668
+ if isinstance(child, (dict, list)):
4669
+ visit(child)
4670
+
4671
+ visit(fields)
4672
+ return app_keys
4673
+
4674
+
4327
4675
  def _multi_app_list_value(item: JSONObject, *keys: str) -> list[JSONObject]:
4328
4676
  for key in keys:
4329
4677
  value = item.get(key)
@@ -4501,7 +4849,6 @@ def _reserved_system_field_name_failure(
4501
4849
  icon: str | None = None,
4502
4850
  color: str | None = None,
4503
4851
  visibility: JSONObject | None = None,
4504
- create_if_missing: bool = False,
4505
4852
  publish: bool | None = None,
4506
4853
  add_fields: list[JSONObject] | None = None,
4507
4854
  update_fields: list[JSONObject] | None = None,
@@ -4521,7 +4868,6 @@ def _reserved_system_field_name_failure(
4521
4868
  "icon": icon or "",
4522
4869
  "color": color or "",
4523
4870
  "visibility": visibility,
4524
- "create_if_missing": create_if_missing,
4525
4871
  "add_fields": suggested_add_fields,
4526
4872
  "update_fields": update_fields or [],
4527
4873
  "remove_fields": remove_fields or [],
@@ -4550,7 +4896,6 @@ def _reserved_system_field_name_failure_for_apps(
4550
4896
  profile: str,
4551
4897
  package_id: int | None,
4552
4898
  visibility: JSONObject | None,
4553
- create_if_missing: bool,
4554
4899
  publish: bool,
4555
4900
  apps: list[JSONObject],
4556
4901
  ) -> JSONObject | None:
@@ -4566,7 +4911,6 @@ def _reserved_system_field_name_failure_for_apps(
4566
4911
  icon=str(item.get("icon") or ""),
4567
4912
  color=str(item.get("color") or ""),
4568
4913
  visibility=item.get("visibility") if isinstance(item.get("visibility"), dict) else visibility,
4569
- create_if_missing=create_if_missing,
4570
4914
  publish=publish,
4571
4915
  add_fields=list(item.get("add_fields") or item.get("addFields") or []),
4572
4916
  update_fields=list(item.get("update_fields") or item.get("updateFields") or []),
@@ -4584,7 +4928,6 @@ def _reserved_system_field_name_failure_for_apps(
4584
4928
  "profile": profile,
4585
4929
  "package_id": package_id,
4586
4930
  "visibility": visibility,
4587
- "create_if_missing": create_if_missing,
4588
4931
  "publish": publish,
4589
4932
  "apps": fixed_apps,
4590
4933
  }
@@ -4759,12 +5102,13 @@ def _safe_tool_call(
4759
5102
  normalized_args: JSONObject,
4760
5103
  suggested_next_call: JSONObject | None,
4761
5104
  ) -> JSONObject:
5105
+ started_at = time.perf_counter()
4762
5106
  try:
4763
- return call()
5107
+ result = call()
4764
5108
  except (QingflowApiError, RuntimeError) as error:
4765
5109
  api_error = _coerce_api_error(error)
4766
5110
  public_http_status = None if api_error.http_status == 404 else api_error.http_status
4767
- return {
5111
+ result = {
4768
5112
  "status": "failed",
4769
5113
  "error_code": error_code,
4770
5114
  "recoverable": True,
@@ -4786,6 +5130,9 @@ def _safe_tool_call(
4786
5130
  "noop": False,
4787
5131
  "verification": {},
4788
5132
  }
5133
+ if isinstance(result, dict):
5134
+ _merge_duration_breakdown(result, tool_call_ms=_elapsed_ms(started_at))
5135
+ return result
4789
5136
 
4790
5137
 
4791
5138
  def _publicize_package_fields(value):
@@ -4865,6 +5212,8 @@ def _builder_apply_json_paths() -> JSONObject:
4865
5212
  "warnings": "$.warnings",
4866
5213
  "verification": "$.verification",
4867
5214
  "details": "$.details",
5215
+ "duration_breakdown_ms": "$.duration_breakdown_ms",
5216
+ "total_ms": "$.duration_breakdown_ms.total_ms",
4868
5217
  "write_executed": "$.write_executed",
4869
5218
  "write_may_have_succeeded": "$.write_may_have_succeeded",
4870
5219
  "safe_to_retry": "$.safe_to_retry",
@@ -5875,7 +6224,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5875
6224
  "allowed_keys": [
5876
6225
  "package_id",
5877
6226
  "package_name",
5878
- "create_if_missing",
5879
6227
  "icon",
5880
6228
  "color",
5881
6229
  "icon_name",
@@ -5888,7 +6236,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5888
6236
  "aliases": {
5889
6237
  "packageId": "package_id",
5890
6238
  "packageName": "package_name",
5891
- "createIfMissing": "create_if_missing",
5892
6239
  "iconName": "icon",
5893
6240
  "iconColor": "color",
5894
6241
  "icon_config.name": "icon",
@@ -5905,6 +6252,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5905
6252
  },
5906
6253
  "execution_notes": [
5907
6254
  "create or update package metadata, visibility, grouping, and ordering in one call",
6255
+ "create mode: package_name without package_id creates a package",
5908
6256
  "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
5909
6257
  "agent-facing primary icon shape is icon + color; icon_name/icon_color, icon_config, and icon={name,color} are compatibility aliases that normalize to icon/color",
5910
6258
  "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
@@ -5913,6 +6261,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5913
6261
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
5914
6262
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
5915
6263
  "item shapes: {type:'app', app_key}, {type:'portal', dash_key}, or {type:'group', group_id?, name, items:[...]}",
6264
+ "allow_detach=true with flat app/portal items and no groups is treated as an explicit full replacement and skips current layout read plus final package readback",
5916
6265
  "layout apply calls backend package ordering (MoveGroupAuth), so it requires package edit_app permission even when items=[] only clears/deletes existing groups",
5917
6266
  *_VISIBILITY_EXECUTION_NOTES,
5918
6267
  ],
@@ -5932,7 +6281,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5932
6281
  "create_example": {
5933
6282
  "profile": "default",
5934
6283
  "package_name": "项目管理",
5935
- "create_if_missing": True,
5936
6284
  "icon": "briefcase",
5937
6285
  "color": "azure",
5938
6286
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
@@ -6151,6 +6499,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6151
6499
  "remove_buttons supports button_id or exact unique button_text",
6152
6500
  "after a remove_buttons DELETE is sent, the tool verifies deletion by single button_id readback; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
6153
6501
  "if a removed button returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
6502
+ "pure create/update with returned button ids and no view_configs/remove skips the final custom-button inventory read; response details.final_readback_skipped=true",
6154
6503
  "all operations share one edit context and publish after at least one write succeeds; there is no draft-only mode for this tool",
6155
6504
  "background_color and text_color cannot both be white",
6156
6505
  ],
@@ -6296,6 +6645,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6296
6645
  "client_key only lets a view_config reference a resource created earlier in the same apply call through associated_item_refs; it is not persisted and cannot deduplicate later apply calls",
6297
6646
  "remove_associated_item_ids sends DELETE and verifies deletion with one associated-resource pool readback because the backend has no confirmed single-item GET; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
6298
6647
  "if an associated resource delete returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
6648
+ "pure create/update with returned associated_item_id values and no view_configs/remove/reorder skips the final associated-resource pool read; response details.final_readback_skipped=true",
6299
6649
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
6300
6650
  "visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
6301
6651
  ],
@@ -6330,7 +6680,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6330
6680
  },
6331
6681
  },
6332
6682
  "app_schema_plan": {
6333
- "allowed_keys": ["app_key", "package_id", "app_name", "icon", "color", "visibility", "create_if_missing", "add_fields", "update_fields", "remove_fields"],
6683
+ "allowed_keys": ["app_key", "package_id", "app_name", "icon", "color", "visibility", "add_fields", "update_fields", "remove_fields"],
6334
6684
  "aliases": {
6335
6685
  "app_title": "app_name",
6336
6686
  "title": "app_name",
@@ -6385,7 +6735,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6385
6735
  "icon": "briefcase",
6386
6736
  "color": "emerald",
6387
6737
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
6388
- "create_if_missing": True,
6389
6738
  "add_fields": [{"name": "项目名称", "type": "text", "data_title": True}],
6390
6739
  "update_fields": [],
6391
6740
  "remove_fields": [],
@@ -6419,7 +6768,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6419
6768
  "icon_color",
6420
6769
  "icon_config",
6421
6770
  "visibility",
6422
- "create_if_missing",
6423
6771
  "publish",
6424
6772
  "form",
6425
6773
  "add_fields",
@@ -6502,13 +6850,14 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6502
6850
  "execution_notes": [
6503
6851
  "use exactly one resource mode",
6504
6852
  "edit mode: app_key, optional app_name to rename the existing app",
6505
- "create mode: package_id + app_name + create_if_missing=true",
6853
+ "create mode: package_id + app_name",
6854
+ "create mode submits a create request directly after basic package add_app permission check; it does not scan app_name duplicates or reuse an existing app by name",
6506
6855
  "create mode follows backend CreateAppBean: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
6507
- "multi-app mode: pass package_id + apps[]; create_if_missing defaults to true for app_name items and may be set false only when every item uses app_key",
6856
+ "multi-app mode: pass package_id + apps[]; items with app_name and no app_key are created, items with app_key update existing apps",
6508
6857
  "CLI --apps-file primary shape is {package_id, apps:[...]}; raw app arrays and singleton wrapper arrays are compatibility paths, not recommended examples",
6509
6858
  "create app schemas with form: form[].section names form sections; form[].rows[][] contains field objects; builder splits form into field creation plus form layout apply",
6510
6859
  "single-app CLI primary shape is --form-file; multi-app primary shape is apps[].form inside --apps-file",
6511
- "multi-app mode preflights static errors before writing: duplicate client_key/app_name, missing data title on new apps, explicit create_if_missing=false with app_name items, and unresolved target_app_ref/target_app",
6860
+ "multi-app mode validates static payload errors before writing: duplicate client_key/app_name, missing data title on new apps, and unresolved target_app_ref/target_app",
6512
6861
  "multi-app relation fields may use target_app_ref to point at another apps[].client_key; the tool creates/resolves app shells first and compiles it to target_app_key",
6513
6862
  "multi-app relation fields may also use target_app with another apps[].app_name; prefer target_app_ref/client_key when names may collide",
6514
6863
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
@@ -6549,7 +6898,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6549
6898
  "icon": "briefcase",
6550
6899
  "color": "emerald",
6551
6900
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
6552
- "create_if_missing": True,
6553
6901
  "publish": True,
6554
6902
  "form": [
6555
6903
  {