@josephyan/qingflow-app-builder-mcp 1.1.20 → 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,
@@ -107,6 +108,26 @@ def _payload_bool(payload: JSONObject, *keys: str, default: bool = True) -> bool
107
108
  return bool(value)
108
109
 
109
110
 
111
+ def _elapsed_ms(started_at: float) -> int:
112
+ return max(0, int((time.perf_counter() - started_at) * 1000))
113
+
114
+
115
+ def _merge_duration_breakdown(payload: JSONObject, **items: int | None) -> None:
116
+ breakdown = payload.get("duration_breakdown_ms")
117
+ if not isinstance(breakdown, dict):
118
+ breakdown = {}
119
+ payload["duration_breakdown_ms"] = breakdown
120
+ for key, value in items.items():
121
+ if value is None:
122
+ continue
123
+ breakdown[key] = int(value)
124
+
125
+
126
+ def _inline_form_layout_was_handled(payload: JSONObject) -> bool:
127
+ inline_layout = payload.get("inline_form_layout")
128
+ return isinstance(inline_layout, dict) and bool(inline_layout.get("requested"))
129
+
130
+
110
131
  PUBLIC_STABLE_FLOW_NODE_TYPES = ["start", "approve", "fill", "copy", "webhook", "end"]
111
132
  BUILDER_APPLY_SCHEMA_VERSION = "builder.apply.v1"
112
133
  BUILDER_APPLY_TOOL_NAMES = {
@@ -122,6 +143,7 @@ BUILDER_APPLY_TOOL_NAMES = {
122
143
  "portal_delete",
123
144
  "app_publish_verify",
124
145
  }
146
+ MULTI_APP_SCHEMA_APPLY_PARALLEL_LIMIT = 10
125
147
 
126
148
 
127
149
  class AiBuilderTools(ToolBase):
@@ -151,13 +173,28 @@ class AiBuilderTools(ToolBase):
151
173
  )
152
174
 
153
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
+
154
189
  if not isinstance(apps, list) or not apps:
155
- return _attach_builder_apply_envelope(tool_name, _config_failure(
156
- tool_name=tool_name,
157
- message=f"{tool_name} batch mode requires non-empty apps[].",
158
- fix_hint="Pass apps as a JSON array; each item must include app_key plus that resource's normal payload.",
159
- details={"expected_shape": {"apps": [{"app_key": "APP_KEY"}]}},
160
- ))
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
+ )
161
198
 
162
199
  app_results: list[JSONObject] = []
163
200
  errors: list[JSONObject] = []
@@ -186,8 +223,10 @@ class AiBuilderTools(ToolBase):
186
223
  app_results.append(error)
187
224
  continue
188
225
  try:
226
+ item_started_at = time.perf_counter()
189
227
  result = apply_one(index, item, app_key)
190
228
  except (QingflowApiError, RuntimeError) as error:
229
+ batch_item_apply_ms += _elapsed_ms(item_started_at)
191
230
  api_error = _coerce_api_error(error)
192
231
  result = {
193
232
  "status": "failed",
@@ -201,6 +240,13 @@ class AiBuilderTools(ToolBase):
201
240
  "backend_code": api_error.backend_code,
202
241
  "http_status": None if api_error.http_status == 404 else api_error.http_status,
203
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)
204
250
  status = str(result.get("status") or "success") if isinstance(result, dict) else "failed"
205
251
  wrapped: JSONObject = {
206
252
  "index": index,
@@ -250,7 +296,7 @@ class AiBuilderTools(ToolBase):
250
296
  "write_succeeded": write_executed and failed == 0 and partial == 0,
251
297
  "safe_to_retry": not write_executed,
252
298
  }
253
- return _attach_builder_apply_envelope(tool_name, payload)
299
+ return finish(payload)
254
300
 
255
301
  def _read_app_batch(self, *, tool_name: str, profile: str, app_keys: list[str], read_one) -> JSONObject:
256
302
  normalized_keys = [str(item).strip() for item in app_keys if str(item).strip()]
@@ -336,7 +382,6 @@ class AiBuilderTools(ToolBase):
336
382
  profile: str = DEFAULT_PROFILE,
337
383
  package_id: int | None = None,
338
384
  package_name: str | None = None,
339
- create_if_missing: bool | None = None,
340
385
  icon: str | JSONObject | None = None,
341
386
  color: str | None = None,
342
387
  icon_name: str | None = None,
@@ -350,7 +395,6 @@ class AiBuilderTools(ToolBase):
350
395
  profile=profile,
351
396
  package_id=package_id,
352
397
  package_name=package_name,
353
- create_if_missing=create_if_missing,
354
398
  icon=icon,
355
399
  color=color,
356
400
  icon_name=icon_name,
@@ -579,7 +623,6 @@ class AiBuilderTools(ToolBase):
579
623
  icon_color: str | None = None,
580
624
  icon_config: JSONObject | None = None,
581
625
  visibility: JSONObject | None = None,
582
- create_if_missing: bool | None = None,
583
626
  publish: bool = True,
584
627
  form: list[JSONObject] | None = None,
585
628
  add_fields: list[JSONObject] | None = None,
@@ -591,7 +634,7 @@ class AiBuilderTools(ToolBase):
591
634
  if app_key or app_name or app_title or form or add_fields or update_fields or remove_fields:
592
635
  return _config_failure(
593
636
  tool_name="app_schema_apply",
594
- 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.",
595
638
  fix_hint="Put per-app form in apps[].form when using batch mode.",
596
639
  )
597
640
  if package_id is None:
@@ -604,7 +647,6 @@ class AiBuilderTools(ToolBase):
604
647
  profile=profile,
605
648
  package_id=package_id,
606
649
  visibility=visibility,
607
- create_if_missing=True if create_if_missing is None else bool(create_if_missing),
608
650
  publish=publish,
609
651
  apps=apps,
610
652
  form=None,
@@ -616,20 +658,19 @@ class AiBuilderTools(ToolBase):
616
658
  has_app_name = bool((app_name or "").strip())
617
659
  has_app_title = bool((app_title or "").strip())
618
660
  has_package_id = package_id is not None
619
- effective_create_if_missing = bool(create_if_missing)
620
661
  if has_app_key:
621
- if effective_create_if_missing or has_package_id:
662
+ if has_package_id:
622
663
  return _config_failure(
623
664
  tool_name="app_schema_apply",
624
665
  message="app_schema_apply edit mode accepts app_key and optional app_name rename only.",
625
- 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`.",
626
667
  )
627
- 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)):
628
669
  return _config_failure(
629
670
  tool_name="app_schema_apply",
630
- message="app_schema_apply create mode requires package_id, app_name, and create_if_missing=true.",
631
- fix_hint="Use `app_key` for existing apps, or pass `package_id + app_name + create_if_missing=true` to create a new app.",
632
- )
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
+ )
633
674
  return self.app_schema_apply(
634
675
  profile=profile,
635
676
  app_key=app_key,
@@ -642,7 +683,6 @@ class AiBuilderTools(ToolBase):
642
683
  icon_color=icon_color,
643
684
  icon_config=icon_config,
644
685
  visibility=visibility,
645
- create_if_missing=effective_create_if_missing,
646
686
  publish=publish,
647
687
  form=form or [],
648
688
  add_fields=add_fields or [],
@@ -989,7 +1029,6 @@ class AiBuilderTools(ToolBase):
989
1029
  profile: str,
990
1030
  package_id: int | None = None,
991
1031
  package_name: str | None = None,
992
- create_if_missing: bool | None = None,
993
1032
  icon: str | JSONObject | None = None,
994
1033
  color: str | None = None,
995
1034
  icon_name: str | None = None,
@@ -1020,14 +1059,13 @@ class AiBuilderTools(ToolBase):
1020
1059
  tool_name="package_apply",
1021
1060
  icon=icon,
1022
1061
  color=color,
1023
- creating=package_id is None and bool(create_if_missing),
1062
+ creating=package_id is None and bool(str(package_name or "").strip()),
1024
1063
  )
1025
1064
  if icon_failure is not None:
1026
1065
  return _attach_builder_apply_envelope("package_apply", icon_failure)
1027
1066
  normalized_args = {
1028
1067
  "package_id": package_id,
1029
1068
  **({"package_name": package_name} if str(package_name or "").strip() else {}),
1030
- "create_if_missing": bool(create_if_missing),
1031
1069
  **({"icon": icon} if icon else {}),
1032
1070
  **({"color": color} if color else {}),
1033
1071
  **({"visibility": visibility_patch.model_dump(mode="json")} if visibility_patch is not None else {}),
@@ -1039,7 +1077,6 @@ class AiBuilderTools(ToolBase):
1039
1077
  profile=profile,
1040
1078
  package_id=package_id,
1041
1079
  package_name=package_name,
1042
- create_if_missing=create_if_missing,
1043
1080
  icon=icon,
1044
1081
  color=color,
1045
1082
  visibility=visibility_patch,
@@ -1859,7 +1896,6 @@ class AiBuilderTools(ToolBase):
1859
1896
  icon: str = "",
1860
1897
  color: str = "",
1861
1898
  visibility: JSONObject | None = None,
1862
- create_if_missing: bool | None = None,
1863
1899
  add_fields: list[JSONObject],
1864
1900
  update_fields: list[JSONObject],
1865
1901
  remove_fields: list[JSONObject],
@@ -1874,7 +1910,6 @@ class AiBuilderTools(ToolBase):
1874
1910
  icon=icon,
1875
1911
  color=color,
1876
1912
  visibility=visibility,
1877
- create_if_missing=create_if_missing,
1878
1913
  add_fields=add_fields,
1879
1914
  update_fields=update_fields,
1880
1915
  remove_fields=remove_fields,
@@ -1890,7 +1925,6 @@ class AiBuilderTools(ToolBase):
1890
1925
  "icon": icon,
1891
1926
  "color": color,
1892
1927
  "visibility": visibility,
1893
- "create_if_missing": create_if_missing,
1894
1928
  "add_fields": add_fields,
1895
1929
  "update_fields": update_fields,
1896
1930
  "remove_fields": remove_fields,
@@ -1911,7 +1945,6 @@ class AiBuilderTools(ToolBase):
1911
1945
  "icon": icon,
1912
1946
  "color": color,
1913
1947
  "visibility": visibility,
1914
- "create_if_missing": create_if_missing,
1915
1948
  "add_fields": [{"name": "字段名称", "type": "text"}],
1916
1949
  "update_fields": [],
1917
1950
  "remove_fields": [],
@@ -2106,7 +2139,6 @@ class AiBuilderTools(ToolBase):
2106
2139
  icon_color: str | None = None,
2107
2140
  icon_config: JSONObject | None = None,
2108
2141
  visibility: JSONObject | None = None,
2109
- create_if_missing: bool | None = None,
2110
2142
  publish: bool = True,
2111
2143
  form: list[JSONObject] | None = None,
2112
2144
  add_fields: list[JSONObject],
@@ -2153,7 +2185,6 @@ class AiBuilderTools(ToolBase):
2153
2185
  profile=profile,
2154
2186
  package_id=package_id,
2155
2187
  visibility=visibility,
2156
- create_if_missing=True if create_if_missing is None else bool(create_if_missing),
2157
2188
  publish=publish,
2158
2189
  apps=apps,
2159
2190
  )
@@ -2187,7 +2218,6 @@ class AiBuilderTools(ToolBase):
2187
2218
  icon=icon,
2188
2219
  color=color,
2189
2220
  visibility=visibility,
2190
- create_if_missing=bool(create_if_missing),
2191
2221
  publish=publish,
2192
2222
  add_fields=add_fields,
2193
2223
  update_fields=update_fields,
@@ -2206,7 +2236,6 @@ class AiBuilderTools(ToolBase):
2206
2236
  icon=icon,
2207
2237
  color=color,
2208
2238
  visibility=visibility,
2209
- create_if_missing=create_if_missing,
2210
2239
  publish=publish,
2211
2240
  add_fields=add_fields,
2212
2241
  update_fields=update_fields,
@@ -2222,52 +2251,114 @@ class AiBuilderTools(ToolBase):
2222
2251
  profile: str,
2223
2252
  package_id: int | None,
2224
2253
  visibility: JSONObject | None,
2225
- create_if_missing: bool,
2226
2254
  publish: bool,
2227
2255
  apps: list[JSONObject],
2228
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
2229
2267
  normalized_args: JSONObject = {
2230
2268
  "package_id": package_id,
2231
- "create_if_missing": create_if_missing,
2232
2269
  "publish": publish,
2233
2270
  "apps": deepcopy(apps),
2234
2271
  }
2235
2272
  if visibility is not None:
2236
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()
2237
2321
  system_field_failure = _reserved_system_field_name_failure_for_apps(
2238
2322
  tool_name="app_schema_apply",
2239
2323
  profile=profile,
2240
2324
  package_id=package_id,
2241
2325
  visibility=visibility,
2242
- create_if_missing=create_if_missing,
2243
2326
  publish=publish,
2244
2327
  apps=apps,
2245
2328
  )
2246
2329
  if system_field_failure is not None:
2247
- return system_field_failure
2330
+ validation_ms += _elapsed_ms(validation_started_at)
2331
+ return finish(system_field_failure)
2248
2332
  if package_id is None:
2249
- return _config_failure(
2250
- tool_name="app_schema_apply",
2251
- message="app_schema_apply multi-app mode requires package_id.",
2252
- 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
+ )
2253
2340
  )
2254
2341
  if not apps:
2255
- return _config_failure(
2256
- tool_name="app_schema_apply",
2257
- error_code="APPS_FILE_EMPTY",
2258
- message="app_schema_apply multi-app mode requires non-empty apps.",
2259
- 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
+ )
2260
2350
  )
2261
2351
  shape_failure = _schema_apps_shape_failure(tool_name="app_schema_apply", apps=apps)
2262
2352
  if shape_failure is not None:
2263
- return shape_failure
2353
+ validation_ms += _elapsed_ms(validation_started_at)
2354
+ return finish(shape_failure)
2264
2355
  static_validation_failure = _multi_app_static_validation_failure(
2265
2356
  tool_name="app_schema_apply",
2266
2357
  apps=apps,
2267
- create_if_missing=create_if_missing,
2268
2358
  )
2269
2359
  if static_validation_failure is not None:
2270
- return static_validation_failure
2360
+ validation_ms += _elapsed_ms(validation_started_at)
2361
+ return finish(static_validation_failure)
2271
2362
  icon_errors: list[JSONObject] = []
2272
2363
  seen_new_app_icons: dict[str, int] = {}
2273
2364
  for index, raw_item in enumerate(apps):
@@ -2318,32 +2409,111 @@ class AiBuilderTools(ToolBase):
2318
2409
  else:
2319
2410
  seen_new_app_icons[normalized_icon] = index
2320
2411
  if icon_errors:
2321
- return _config_failure(
2322
- tool_name="app_schema_apply",
2323
- error_code="WORKSPACE_ICON_BATCH_INVALID",
2324
- message="one or more apps have invalid workspace icon configuration",
2325
- fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
2326
- details={"icon_errors": icon_errors},
2327
- allowed_values={
2328
- "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
2329
- "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
2330
- "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
2331
- },
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
+ )
2332
2426
  )
2427
+ validation_ms += _elapsed_ms(validation_started_at)
2333
2428
 
2334
2429
  client_key_to_app_key: dict[str, str] = {}
2335
2430
  app_name_to_app_key: dict[str, str] = {}
2336
2431
  created_app_keys: list[str] = []
2337
- results: list[JSONObject] = []
2432
+ results_by_index: list[JSONObject | None] = [None] * len(apps)
2433
+ shell_jobs: list[JSONObject] = []
2338
2434
  any_write_executed = False
2339
2435
  any_write_may_have_succeeded = False
2340
2436
  pending_readback_app_keys: list[str] = []
2341
2437
  pending_readback_app_names: list[str] = []
2342
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
2343
2513
 
2344
2514
  for index, raw_item in enumerate(apps):
2345
2515
  if not isinstance(raw_item, dict):
2346
- 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")
2347
2517
  continue
2348
2518
  item = deepcopy(raw_item)
2349
2519
  client_key = str(item.get("client_key") or item.get("clientKey") or "").strip()
@@ -2351,31 +2521,104 @@ class AiBuilderTools(ToolBase):
2351
2521
  app_key = str(item.get("app_key") or item.get("appKey") or "").strip()
2352
2522
  if client_key:
2353
2523
  if client_key in client_keys:
2354
- 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}'")
2355
2525
  continue
2356
2526
  client_keys.add(client_key)
2527
+ client_key_indexes[client_key] = index
2528
+ if app_name:
2529
+ app_name_indexes[app_name] = index
2357
2530
  if not app_key and not app_name:
2358
- 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")
2359
2532
  continue
2360
-
2361
- initial_add_fields, deferred_add_fields = _split_multi_app_initial_add_fields(item, is_new_app=not bool(app_key))
2362
- item["_deferred_add_fields"] = deferred_add_fields
2363
- shell = self._app_schema_apply_once(
2364
- profile=profile,
2365
- app_key=app_key,
2366
- package_id=package_id if not app_key else None,
2367
- app_name=app_name,
2368
- app_title="",
2369
- icon=str(item.get("icon") or ""),
2370
- color=str(item.get("color") or ""),
2371
- visibility=item.get("visibility", visibility),
2372
- create_if_missing=create_if_missing and not app_key,
2373
- publish=publish and not deferred_add_fields,
2374
- add_fields=initial_add_fields,
2375
- update_fields=[],
2376
- remove_fields=[],
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
+ }
2377
2541
  )
2378
- 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)
2379
2622
  resolved_key = str(public_shell.get("app_key") or "").strip()
2380
2623
  shell_write_executed = _schema_apply_result_has_write(public_shell)
2381
2624
  shell_write_may_have_succeeded = _schema_apply_result_may_have_write(public_shell)
@@ -2395,7 +2638,7 @@ class AiBuilderTools(ToolBase):
2395
2638
  pending_readback_app_keys.append(resolved_key)
2396
2639
  if app_name:
2397
2640
  pending_readback_app_names.append(app_name)
2398
- results.append({
2641
+ results_by_index[index] = {
2399
2642
  "index": index,
2400
2643
  "row_number": index + 1,
2401
2644
  "client_key": client_key or None,
@@ -2410,8 +2653,8 @@ class AiBuilderTools(ToolBase):
2410
2653
  "safe_to_retry": False if pending_readback else (not shell_write_executed and not any_write_executed),
2411
2654
  **({"next_action": "readback_before_retry"} if pending_readback else {}),
2412
2655
  **({"verification": public_shell.get("verification")} if isinstance(public_shell.get("verification"), dict) else {}),
2413
- **({"created": True} if pending_readback and create_if_missing and not app_key else {}),
2414
- })
2656
+ **({"created": True} if pending_readback and not app_key else {}),
2657
+ }
2415
2658
  continue
2416
2659
  if bool(public_shell.get("created")):
2417
2660
  created_app_keys.append(resolved_key)
@@ -2424,7 +2667,7 @@ class AiBuilderTools(ToolBase):
2424
2667
  resolved_name = str(public_shell.get("app_name_after") or public_shell.get("app_name") or app_name or "").strip()
2425
2668
  if resolved_name:
2426
2669
  app_name_to_app_key[resolved_name] = resolved_key
2427
- results.append({
2670
+ results_by_index[index] = {
2428
2671
  "index": index,
2429
2672
  "row_number": index + 1,
2430
2673
  "client_key": client_key or None,
@@ -2435,104 +2678,218 @@ class AiBuilderTools(ToolBase):
2435
2678
  "shell_result": public_shell,
2436
2679
  "shell_field_diff": public_shell.get("field_diff") or {},
2437
2680
  "shell_field_diff_details": public_shell.get("field_diff_details") or {},
2438
- "deferred_add_fields": deferred_add_fields,
2439
- })
2681
+ }
2440
2682
 
2441
- final_items: list[JSONObject] = []
2683
+ final_items_by_index: list[JSONObject | None] = [None] * len(apps)
2684
+ schema_jobs: list[JSONObject] = []
2442
2685
  for index, raw_item in enumerate(apps):
2443
- 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
2444
2687
  if not existing or existing.get("status") != "shell_ready":
2445
2688
  if existing:
2446
- final_items.append(existing)
2689
+ final_items_by_index[index] = existing
2447
2690
  continue
2448
2691
  item = deepcopy(raw_item)
2449
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()
2450
2699
  try:
2451
2700
  compiled_item = _compile_multi_app_schema_item_refs(item, client_key_to_app_key, app_name_to_app_key)
2452
2701
  except ValueError as error:
2453
- final_items.append({
2702
+ relation_compile_ms += _elapsed_ms(compile_started_at)
2703
+ final_items_by_index[index] = {
2454
2704
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2455
2705
  "status": "failed",
2456
2706
  "stage": "compile_relation_refs",
2457
2707
  "error_code": "TARGET_APP_REF_NOT_FOUND",
2458
2708
  "message": str(error),
2459
2709
  "safe_to_retry": False,
2460
- })
2710
+ }
2461
2711
  any_write_executed = True
2462
2712
  continue
2463
-
2464
- deferred_add_fields = (
2465
- _compiled_multi_app_deferred_add_fields(compiled_item, existing)
2466
- if bool(existing.get("created"))
2467
- else list(compiled_item.get("add_fields") or [])
2468
- )
2713
+ relation_compile_ms += _elapsed_ms(compile_started_at)
2714
+ schema_add_fields = list(compiled_item.get("add_fields") or [])
2469
2715
  update_fields = list(compiled_item.get("update_fields") or [])
2470
2716
  remove_fields = list(compiled_item.get("remove_fields") or [])
2471
- if bool(existing.get("created")) and not deferred_add_fields and not update_fields and not remove_fields:
2472
- shell_result = existing.get("shell_result") if isinstance(existing.get("shell_result"), dict) else {}
2473
- layout_sections = list(compiled_item.get("_inline_form_layout_sections") or [])
2474
- if layout_sections:
2475
- 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(
2796
+ profile=profile,
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(
2476
2802
  profile=profile,
2477
- schema_result=shell_result,
2478
- fallback_app_key=app_key,
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,
2479
2810
  publish=publish,
2480
- 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,
2816
+ )
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
+ }
2481
2828
  )
2482
- if _schema_apply_result_has_write(shell_result):
2483
- any_write_executed = True
2484
- if _schema_apply_result_may_have_write(shell_result):
2485
- any_write_may_have_succeeded = True
2486
- item_status = shell_result.get("status") if shell_result.get("status") in {"success", "partial_success"} else "failed"
2487
- shell_field_diff = existing.get("shell_field_diff") if isinstance(existing.get("shell_field_diff"), dict) else {}
2488
- shell_field_diff_details = existing.get("shell_field_diff_details") if isinstance(existing.get("shell_field_diff_details"), dict) else {}
2489
- final_items.append({
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] = {
2490
2875
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2491
- "status": item_status,
2876
+ "status": "pending_readback",
2492
2877
  "stage": "schema_apply",
2493
- "field_diff": shell_field_diff,
2494
- "field_diff_details": shell_field_diff_details,
2495
- "shell_field_diff": shell_field_diff,
2496
- "shell_field_diff_details": shell_field_diff_details,
2497
- "published": bool(shell_result.get("published")),
2498
- "verified": bool(shell_result.get("verified")),
2499
- "error_code": shell_result.get("error_code"),
2500
- "message": shell_result.get("message"),
2501
- "write_executed": _schema_apply_result_has_write(shell_result),
2502
- "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,
2503
2882
  "safe_to_retry": False,
2504
- **({"next_action": shell_result.get("next_action")} if shell_result.get("next_action") else {}),
2505
- **({"verification": shell_result.get("verification")} if isinstance(shell_result.get("verification"), dict) else {}),
2506
- **({"inline_form_layout": shell_result.get("inline_form_layout")} if isinstance(shell_result.get("inline_form_layout"), dict) else {}),
2507
- **({"inline_form_layout_result": shell_result.get("inline_form_layout_result")} if isinstance(shell_result.get("inline_form_layout_result"), dict) else {}),
2508
- })
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
2509
2890
  continue
2510
-
2511
- field_result = self._app_schema_apply_once(
2512
- profile=profile,
2513
- app_key=app_key,
2514
- package_id=None,
2515
- app_name=str(compiled_item.get("app_name") or compiled_item.get("appTitle") or compiled_item.get("app_title") or ""),
2516
- app_title="",
2517
- icon=str(compiled_item.get("icon") or ""),
2518
- color=str(compiled_item.get("color") or ""),
2519
- visibility=compiled_item.get("visibility"),
2520
- create_if_missing=False,
2521
- publish=publish,
2522
- add_fields=deferred_add_fields,
2523
- update_fields=update_fields,
2524
- remove_fields=remove_fields,
2525
- )
2526
- public_result = _publicize_package_fields(field_result)
2527
- layout_sections = list(compiled_item.get("_inline_form_layout_sections") or [])
2528
- if layout_sections:
2529
- public_result = self._apply_inline_form_layout_after_schema(
2530
- profile=profile,
2531
- schema_result=public_result,
2532
- fallback_app_key=app_key,
2533
- publish=publish,
2534
- sections=layout_sections,
2535
- )
2891
+ public_result = output.get("public_result") if isinstance(output.get("public_result"), dict) else {}
2892
+ merge_child_duration(public_result)
2536
2893
  if _schema_apply_result_has_write(public_result):
2537
2894
  any_write_executed = True
2538
2895
  if _schema_apply_result_may_have_write(public_result):
@@ -2547,7 +2904,7 @@ class AiBuilderTools(ToolBase):
2547
2904
  pending_readback_app_keys.append(app_key)
2548
2905
  if existing.get("app_name"):
2549
2906
  pending_readback_app_names.append(str(existing.get("app_name")))
2550
- final_items.append({
2907
+ final_items_by_index[index] = {
2551
2908
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2552
2909
  "status": item_status,
2553
2910
  "stage": "schema_apply",
@@ -2562,12 +2919,17 @@ class AiBuilderTools(ToolBase):
2562
2919
  "write_executed": _schema_apply_result_has_write(public_result),
2563
2920
  "write_may_have_succeeded": _schema_apply_result_may_have_write(public_result),
2564
2921
  "safe_to_retry": False,
2922
+ **_multi_app_child_diagnostics(public_result),
2565
2923
  **({"next_action": public_result.get("next_action")} if public_result.get("next_action") else {}),
2566
2924
  **({"verification": public_result.get("verification")} if isinstance(public_result.get("verification"), dict) else {}),
2567
2925
  **({"inline_form_layout": public_result.get("inline_form_layout")} if isinstance(public_result.get("inline_form_layout"), dict) else {}),
2568
2926
  **({"inline_form_layout_result": public_result.get("inline_form_layout_result")} if isinstance(public_result.get("inline_form_layout_result"), dict) else {}),
2569
- })
2927
+ }
2570
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
+ ]
2571
2933
  pending_readback = sum(1 for item in final_items if item.get("status") == "pending_readback")
2572
2934
  succeeded = sum(1 for item in final_items if item.get("status") in {"success", "partial_success"})
2573
2935
  failed = sum(1 for item in final_items if item.get("status") == "failed")
@@ -2591,6 +2953,7 @@ class AiBuilderTools(ToolBase):
2591
2953
  "package_id": package_id,
2592
2954
  "publish_requested": publish,
2593
2955
  "apps": final_items,
2956
+ "parallelism": parallelism,
2594
2957
  "normalized_args": normalized_args,
2595
2958
  "verification": {
2596
2959
  "all_apps_succeeded": failed == 0,
@@ -2611,7 +2974,69 @@ class AiBuilderTools(ToolBase):
2611
2974
  response["next_action"] = "readback_before_retry"
2612
2975
  response["pending_readback_app_keys"] = list(dict.fromkeys(pending_readback_app_keys))
2613
2976
  response["pending_readback_app_names"] = list(dict.fromkeys(pending_readback_app_names))
2614
- 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
+ }
2615
3040
 
2616
3041
  def _app_schema_apply_once(
2617
3042
  self,
@@ -2624,15 +3049,17 @@ class AiBuilderTools(ToolBase):
2624
3049
  icon: str = "",
2625
3050
  color: str = "",
2626
3051
  visibility: JSONObject | None = None,
2627
- create_if_missing: bool = False,
2628
3052
  publish: bool = True,
2629
3053
  add_fields: list[JSONObject],
2630
3054
  update_fields: list[JSONObject],
2631
3055
  remove_fields: list[JSONObject],
2632
3056
  inline_form_layout_sections: list[JSONObject] | None = None,
3057
+ allow_inline_layout_fallback: bool = True,
2633
3058
  ) -> JSONObject:
2634
3059
  """执行内部辅助逻辑。"""
3060
+ started_at = time.perf_counter()
2635
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())
2636
3063
  system_field_failure = _reserved_system_field_name_failure(
2637
3064
  tool_name="app_schema_apply",
2638
3065
  profile=profile,
@@ -2642,7 +3069,6 @@ class AiBuilderTools(ToolBase):
2642
3069
  icon=icon,
2643
3070
  color=color,
2644
3071
  visibility=visibility,
2645
- create_if_missing=create_if_missing,
2646
3072
  publish=publish,
2647
3073
  add_fields=add_fields,
2648
3074
  update_fields=update_fields,
@@ -2654,38 +3080,19 @@ class AiBuilderTools(ToolBase):
2654
3080
  tool_name="app_schema_apply",
2655
3081
  icon=icon,
2656
3082
  color=color,
2657
- creating=not bool(str(app_key or "").strip()) and bool(create_if_missing),
3083
+ creating=creating,
2658
3084
  )
2659
3085
  if icon_failure is not None:
2660
3086
  return icon_failure
2661
- plan_result = self._rewrite_plan_result_for_apply(
2662
- result=self.app_schema_plan(
2663
- profile=profile,
2664
- app_key=app_key,
2665
- package_id=package_id,
2666
- app_name=effective_app_name,
2667
- icon=icon,
2668
- color=color,
2669
- visibility=visibility,
2670
- create_if_missing=create_if_missing,
2671
- add_fields=add_fields,
2672
- update_fields=update_fields,
2673
- remove_fields=remove_fields,
2674
- ),
2675
- profile=profile,
2676
- publish=publish,
2677
- plan_tool_name="app_schema_plan",
2678
- apply_tool_name="app_schema_apply",
2679
- )
2680
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
2681
- return plan_result
2682
- plan_args = plan_result.get("normalized_args")
2683
- if not isinstance(plan_args, dict):
2684
- plan_args = {}
2685
3087
  try:
2686
- parsed_add = [FieldPatch.model_validate(item) for item in plan_args.get("add_fields") or []]
2687
- parsed_update = [FieldUpdatePatch.model_validate(item) for item in plan_args.get("update_fields") or []]
2688
- 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 []]
3091
+ parsed_inline_layout = [
3092
+ LayoutSectionPatch.model_validate(item)
3093
+ for item in (inline_form_layout_sections or [])
3094
+ ]
3095
+ parsed_visibility = VisibilityPatch.model_validate(visibility) if visibility is not None else None
2689
3096
  except ValidationError as exc:
2690
3097
  return _validation_failure(
2691
3098
  str(exc),
@@ -2695,61 +3102,75 @@ class AiBuilderTools(ToolBase):
2695
3102
  "tool_name": "app_schema_apply",
2696
3103
  "arguments": {
2697
3104
  "profile": profile,
2698
- "app_key": str(plan_args.get("app_key") or app_key),
2699
- "package_id": plan_args.get("package_id", package_id),
2700
- "app_name": str(plan_args.get("app_name") or effective_app_name),
2701
- "icon": str(plan_args.get("icon") or icon),
2702
- "color": str(plan_args.get("color") or color),
2703
- "visibility": plan_args.get("visibility") or visibility,
2704
- "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,
2705
3111
  "publish": publish,
2706
- "add_fields": plan_args.get("add_fields") or [{"name": "字段名称", "type": "text", "required": False}],
2707
- "update_fields": plan_args.get("update_fields") or [],
2708
- "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 [],
2709
3115
  },
2710
3116
  },
2711
3117
  )
2712
3118
  normalized_args = {
2713
- "app_key": str(plan_args.get("app_key") or app_key),
2714
- "package_id": plan_args.get("package_id", package_id),
2715
- "app_name": str(plan_args.get("app_name") or effective_app_name),
2716
- "icon": str(plan_args.get("icon") or icon or ""),
2717
- "color": str(plan_args.get("color") or color or ""),
2718
- "visibility": plan_args.get("visibility") or visibility,
2719
- "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,
2720
3125
  "publish": publish,
2721
3126
  "add_fields": [patch.model_dump(mode="json") for patch in parsed_add],
2722
3127
  "update_fields": [patch.model_dump(mode="json") for patch in parsed_update],
2723
3128
  "remove_fields": [patch.model_dump(mode="json") for patch in parsed_remove],
3129
+ **(
3130
+ {
3131
+ "inline_form_layout_sections": [
3132
+ section.model_dump(mode="json", exclude_none=True)
3133
+ for section in parsed_inline_layout
3134
+ ]
3135
+ }
3136
+ if parsed_inline_layout
3137
+ else {}
3138
+ ),
2724
3139
  }
2725
3140
  result = _safe_tool_call(
2726
3141
  lambda: self._facade.app_schema_apply(
2727
3142
  profile=profile,
2728
- app_key=str(plan_args.get("app_key") or app_key),
2729
- package_tag_id=plan_args.get("package_id", package_id),
2730
- app_name=str(plan_args.get("app_name") or effective_app_name),
2731
- icon=str(plan_args.get("icon") or icon or ""),
2732
- color=str(plan_args.get("color") or color or ""),
2733
- visibility=VisibilityPatch.model_validate(plan_args.get("visibility")) if plan_args.get("visibility") is not None else None,
2734
- 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,
2735
3149
  publish=publish,
2736
3150
  add_fields=parsed_add,
2737
3151
  update_fields=parsed_update,
2738
3152
  remove_fields=parsed_remove,
3153
+ inline_form_layout_sections=parsed_inline_layout,
2739
3154
  ),
2740
3155
  error_code="SCHEMA_APPLY_FAILED",
2741
3156
  normalized_args=normalized_args,
2742
3157
  suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
2743
3158
  )
2744
3159
  public_result = _publicize_package_fields(result)
2745
- if inline_form_layout_sections:
3160
+ if inline_form_layout_sections and allow_inline_layout_fallback and not _inline_form_layout_was_handled(public_result):
3161
+ layout_started_at = time.perf_counter()
2746
3162
  public_result = self._apply_inline_form_layout_after_schema(
2747
3163
  profile=profile,
2748
3164
  schema_result=public_result,
2749
- fallback_app_key=str(plan_args.get("app_key") or app_key),
3165
+ fallback_app_key=str(app_key or ""),
2750
3166
  publish=publish,
2751
3167
  sections=inline_form_layout_sections,
2752
3168
  )
3169
+ _merge_duration_breakdown(
3170
+ public_result,
3171
+ inline_layout_ms=_elapsed_ms(layout_started_at),
3172
+ )
3173
+ _merge_duration_breakdown(public_result, total_ms=_elapsed_ms(started_at))
2753
3174
  return public_result
2754
3175
 
2755
3176
  def _apply_inline_form_layout_after_schema(
@@ -2867,27 +3288,9 @@ class AiBuilderTools(ToolBase):
2867
3288
 
2868
3289
  def _app_layout_apply_once(self, *, profile: str, app_key: str, mode: str = "merge", publish: bool = True, sections: list[JSONObject]) -> JSONObject:
2869
3290
  """执行内部辅助逻辑。"""
2870
- plan_result = self._rewrite_plan_result_for_apply(
2871
- result=self.app_layout_plan(
2872
- profile=profile,
2873
- app_key=app_key,
2874
- mode=mode,
2875
- sections=sections,
2876
- preset=None,
2877
- ),
2878
- profile=profile,
2879
- publish=publish,
2880
- plan_tool_name="app_layout_plan",
2881
- apply_tool_name="app_layout_apply",
2882
- )
2883
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
2884
- return plan_result
2885
- plan_args = plan_result.get("normalized_args")
2886
- if not isinstance(plan_args, dict):
2887
- plan_args = {}
2888
3291
  try:
2889
- parsed_mode = LayoutApplyMode(str(plan_args.get("mode") or mode))
2890
- 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 []]
2891
3294
  except (ValueError, ValidationError) as exc:
2892
3295
  return _validation_failure(
2893
3296
  str(exc),
@@ -2897,15 +3300,15 @@ class AiBuilderTools(ToolBase):
2897
3300
  "tool_name": "app_layout_apply",
2898
3301
  "arguments": {
2899
3302
  "profile": profile,
2900
- "app_key": str(plan_args.get("app_key") or app_key),
2901
- "mode": str(plan_args.get("mode") or "merge"),
3303
+ "app_key": app_key,
3304
+ "mode": str(mode or "merge"),
2902
3305
  "publish": publish,
2903
- "sections": plan_args.get("sections") or [{"title": "基础信息", "rows": [["字段A", "字段B"]]}],
3306
+ "sections": sections or [{"title": "基础信息", "rows": [["字段A", "字段B"]]}],
2904
3307
  },
2905
3308
  },
2906
3309
  )
2907
3310
  normalized_args = {
2908
- "app_key": str(plan_args.get("app_key") or app_key),
3311
+ "app_key": app_key,
2909
3312
  "mode": parsed_mode.value,
2910
3313
  "publish": publish,
2911
3314
  "sections": [section.model_dump(mode="json", exclude_none=True) for section in parsed_sections],
@@ -2913,7 +3316,7 @@ class AiBuilderTools(ToolBase):
2913
3316
  return _safe_tool_call(
2914
3317
  lambda: self._facade.app_layout_apply(
2915
3318
  profile=profile,
2916
- app_key=str(plan_args.get("app_key") or app_key),
3319
+ app_key=app_key,
2917
3320
  mode=parsed_mode,
2918
3321
  publish=publish,
2919
3322
  sections=parsed_sections,
@@ -3016,32 +3419,13 @@ class AiBuilderTools(ToolBase):
3016
3419
  transitions: list[JSONObject],
3017
3420
  ) -> JSONObject:
3018
3421
  """执行内部辅助逻辑。"""
3019
- plan_result = self._rewrite_plan_result_for_apply(
3020
- result=self.app_flow_plan(
3021
- profile=profile,
3022
- app_key=app_key,
3023
- mode=mode,
3024
- nodes=nodes,
3025
- transitions=transitions,
3026
- preset=None,
3027
- ),
3028
- profile=profile,
3029
- publish=publish,
3030
- plan_tool_name="app_flow_plan",
3031
- apply_tool_name="app_flow_apply",
3032
- )
3033
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
3034
- return plan_result
3035
- plan_args = plan_result.get("normalized_args")
3036
- if not isinstance(plan_args, dict):
3037
- plan_args = {}
3038
3422
  try:
3039
3423
  request = FlowPlanRequest.model_validate(
3040
3424
  {
3041
- "app_key": plan_args.get("app_key") or app_key,
3042
- "mode": plan_args.get("mode") or mode,
3043
- "nodes": plan_args.get("nodes") or [],
3044
- "transitions": plan_args.get("transitions") or [],
3425
+ "app_key": app_key,
3426
+ "mode": mode,
3427
+ "nodes": nodes or [],
3428
+ "transitions": transitions or [],
3045
3429
  "preset": None,
3046
3430
  }
3047
3431
  )
@@ -3054,11 +3438,11 @@ class AiBuilderTools(ToolBase):
3054
3438
  "tool_name": "app_flow_apply",
3055
3439
  "arguments": {
3056
3440
  "profile": profile,
3057
- "app_key": str(plan_args.get("app_key") or app_key),
3058
- "mode": str(plan_args.get("mode") or "replace"),
3441
+ "app_key": app_key,
3442
+ "mode": str(mode or "replace"),
3059
3443
  "publish": publish,
3060
- "nodes": plan_args.get("nodes") or [{"id": "start", "type": "start", "name": "发起"}],
3061
- "transitions": plan_args.get("transitions") or [],
3444
+ "nodes": nodes or [{"id": "start", "type": "start", "name": "发起"}],
3445
+ "transitions": transitions or [],
3062
3446
  },
3063
3447
  },
3064
3448
  )
@@ -3155,69 +3539,9 @@ class AiBuilderTools(ToolBase):
3155
3539
  )
3156
3540
  if reserved_failure is not None:
3157
3541
  return reserved_failure
3158
- if patch_views or _view_payload_has_action_buttons(upsert_views=upsert_views, patch_views=patch_views):
3159
- try:
3160
- parsed_views = [ViewUpsertPatch.model_validate(item) for item in (upsert_views or [])]
3161
- parsed_patch_views = [ViewPartialPatch.model_validate(item) for item in patch_views]
3162
- except ValidationError as exc:
3163
- return _visibility_validation_failure(
3164
- str(exc),
3165
- tool_name="app_views_apply",
3166
- exc=exc,
3167
- suggested_next_call={
3168
- "tool_name": "app_views_apply",
3169
- "arguments": {
3170
- "profile": profile,
3171
- "app_key": app_key,
3172
- "publish": publish,
3173
- "upsert_views": upsert_views or [],
3174
- "patch_views": [
3175
- {"view_key": "VIEW_KEY", "set": {"query_conditions": {"enabled": True, "rows": [["字段A"]]}}},
3176
- ],
3177
- "remove_views": remove_views or [],
3178
- },
3179
- },
3180
- )
3181
- normalized_args = {
3182
- "app_key": app_key,
3183
- "publish": publish,
3184
- "upsert_views": [public_view_upsert_payload(view) for view in parsed_views],
3185
- "patch_views": [public_view_partial_payload(patch) for patch in parsed_patch_views],
3186
- "remove_views": list(remove_views or []),
3187
- }
3188
- return _safe_tool_call(
3189
- lambda: self._facade.app_views_apply(
3190
- profile=profile,
3191
- app_key=app_key,
3192
- publish=publish,
3193
- upsert_views=parsed_views,
3194
- patch_views=parsed_patch_views,
3195
- remove_views=list(remove_views or []),
3196
- ),
3197
- error_code="VIEWS_APPLY_FAILED",
3198
- normalized_args=normalized_args,
3199
- suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **normalized_args}},
3200
- )
3201
- plan_result = self._rewrite_plan_result_for_apply(
3202
- result=self.app_views_plan(
3203
- profile=profile,
3204
- app_key=app_key,
3205
- upsert_views=upsert_views,
3206
- remove_views=remove_views,
3207
- preset=None,
3208
- ),
3209
- profile=profile,
3210
- publish=publish,
3211
- plan_tool_name="app_views_plan",
3212
- apply_tool_name="app_views_apply",
3213
- )
3214
- if not isinstance(plan_result, dict) or plan_result.get("status") != "success":
3215
- return plan_result
3216
- plan_args = plan_result.get("normalized_args")
3217
- if not isinstance(plan_args, dict):
3218
- plan_args = {}
3219
3542
  try:
3220
- 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 [])]
3221
3545
  except ValidationError as exc:
3222
3546
  return _visibility_validation_failure(
3223
3547
  str(exc),
@@ -3227,28 +3551,29 @@ class AiBuilderTools(ToolBase):
3227
3551
  "tool_name": "app_views_apply",
3228
3552
  "arguments": {
3229
3553
  "profile": profile,
3230
- "app_key": str(plan_args.get("app_key") or app_key),
3554
+ "app_key": app_key,
3231
3555
  "publish": publish,
3232
- "upsert_views": plan_args.get("upsert_views") or [{"name": "业务台账视图", "type": "table", "columns": ["字段A"]}],
3233
- "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 [],
3234
3559
  },
3235
3560
  },
3236
3561
  )
3237
3562
  normalized_args = {
3238
- "app_key": str(plan_args.get("app_key") or app_key),
3563
+ "app_key": app_key,
3239
3564
  "publish": publish,
3240
3565
  "upsert_views": [public_view_upsert_payload(view) for view in parsed_views],
3241
- "patch_views": [],
3242
- "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 []),
3243
3568
  }
3244
3569
  return _safe_tool_call(
3245
3570
  lambda: self._facade.app_views_apply(
3246
3571
  profile=profile,
3247
- app_key=str(plan_args.get("app_key") or app_key),
3572
+ app_key=app_key,
3248
3573
  publish=publish,
3249
3574
  upsert_views=parsed_views,
3250
- patch_views=[],
3251
- remove_views=list(plan_args.get("remove_views") or remove_views),
3575
+ patch_views=parsed_patch_views,
3576
+ remove_views=list(remove_views or []),
3252
3577
  ),
3253
3578
  error_code="VIEWS_APPLY_FAILED",
3254
3579
  normalized_args=normalized_args,
@@ -4004,7 +4329,6 @@ def _multi_app_static_validation_failure(
4004
4329
  *,
4005
4330
  tool_name: str,
4006
4331
  apps: list[JSONObject],
4007
- create_if_missing: bool,
4008
4332
  ) -> JSONObject | None:
4009
4333
  issues: list[JSONObject] = []
4010
4334
  client_key_indexes: dict[str, int] = {}
@@ -4067,17 +4391,6 @@ def _multi_app_static_validation_failure(
4067
4391
  }
4068
4392
  )
4069
4393
  continue
4070
- if new_app_item and not create_if_missing:
4071
- issues.append(
4072
- {
4073
- "index": index,
4074
- "row_number": index + 1,
4075
- "path": f"apps[{index}]",
4076
- "error_code": "CREATE_IF_MISSING_REQUIRED",
4077
- "message": "apps[] item with app_name but no app_key cannot run when create_if_missing=false",
4078
- "fix_hint": "Omit create_if_missing in multi-app mode, set it to true, or pass app_key for every existing app item.",
4079
- }
4080
- )
4081
4394
 
4082
4395
  add_fields = _multi_app_list_value(item, "add_fields", "addFields")
4083
4396
  if new_app_item:
@@ -4279,6 +4592,86 @@ def _compiled_multi_app_deferred_add_fields(compiled_item: JSONObject, existing_
4279
4592
  ]
4280
4593
 
4281
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
+
4282
4675
  def _multi_app_list_value(item: JSONObject, *keys: str) -> list[JSONObject]:
4283
4676
  for key in keys:
4284
4677
  value = item.get(key)
@@ -4456,7 +4849,6 @@ def _reserved_system_field_name_failure(
4456
4849
  icon: str | None = None,
4457
4850
  color: str | None = None,
4458
4851
  visibility: JSONObject | None = None,
4459
- create_if_missing: bool = False,
4460
4852
  publish: bool | None = None,
4461
4853
  add_fields: list[JSONObject] | None = None,
4462
4854
  update_fields: list[JSONObject] | None = None,
@@ -4476,7 +4868,6 @@ def _reserved_system_field_name_failure(
4476
4868
  "icon": icon or "",
4477
4869
  "color": color or "",
4478
4870
  "visibility": visibility,
4479
- "create_if_missing": create_if_missing,
4480
4871
  "add_fields": suggested_add_fields,
4481
4872
  "update_fields": update_fields or [],
4482
4873
  "remove_fields": remove_fields or [],
@@ -4505,7 +4896,6 @@ def _reserved_system_field_name_failure_for_apps(
4505
4896
  profile: str,
4506
4897
  package_id: int | None,
4507
4898
  visibility: JSONObject | None,
4508
- create_if_missing: bool,
4509
4899
  publish: bool,
4510
4900
  apps: list[JSONObject],
4511
4901
  ) -> JSONObject | None:
@@ -4521,7 +4911,6 @@ def _reserved_system_field_name_failure_for_apps(
4521
4911
  icon=str(item.get("icon") or ""),
4522
4912
  color=str(item.get("color") or ""),
4523
4913
  visibility=item.get("visibility") if isinstance(item.get("visibility"), dict) else visibility,
4524
- create_if_missing=create_if_missing,
4525
4914
  publish=publish,
4526
4915
  add_fields=list(item.get("add_fields") or item.get("addFields") or []),
4527
4916
  update_fields=list(item.get("update_fields") or item.get("updateFields") or []),
@@ -4539,7 +4928,6 @@ def _reserved_system_field_name_failure_for_apps(
4539
4928
  "profile": profile,
4540
4929
  "package_id": package_id,
4541
4930
  "visibility": visibility,
4542
- "create_if_missing": create_if_missing,
4543
4931
  "publish": publish,
4544
4932
  "apps": fixed_apps,
4545
4933
  }
@@ -4714,12 +5102,13 @@ def _safe_tool_call(
4714
5102
  normalized_args: JSONObject,
4715
5103
  suggested_next_call: JSONObject | None,
4716
5104
  ) -> JSONObject:
5105
+ started_at = time.perf_counter()
4717
5106
  try:
4718
- return call()
5107
+ result = call()
4719
5108
  except (QingflowApiError, RuntimeError) as error:
4720
5109
  api_error = _coerce_api_error(error)
4721
5110
  public_http_status = None if api_error.http_status == 404 else api_error.http_status
4722
- return {
5111
+ result = {
4723
5112
  "status": "failed",
4724
5113
  "error_code": error_code,
4725
5114
  "recoverable": True,
@@ -4741,6 +5130,9 @@ def _safe_tool_call(
4741
5130
  "noop": False,
4742
5131
  "verification": {},
4743
5132
  }
5133
+ if isinstance(result, dict):
5134
+ _merge_duration_breakdown(result, tool_call_ms=_elapsed_ms(started_at))
5135
+ return result
4744
5136
 
4745
5137
 
4746
5138
  def _publicize_package_fields(value):
@@ -4820,6 +5212,8 @@ def _builder_apply_json_paths() -> JSONObject:
4820
5212
  "warnings": "$.warnings",
4821
5213
  "verification": "$.verification",
4822
5214
  "details": "$.details",
5215
+ "duration_breakdown_ms": "$.duration_breakdown_ms",
5216
+ "total_ms": "$.duration_breakdown_ms.total_ms",
4823
5217
  "write_executed": "$.write_executed",
4824
5218
  "write_may_have_succeeded": "$.write_may_have_succeeded",
4825
5219
  "safe_to_retry": "$.safe_to_retry",
@@ -5830,7 +6224,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5830
6224
  "allowed_keys": [
5831
6225
  "package_id",
5832
6226
  "package_name",
5833
- "create_if_missing",
5834
6227
  "icon",
5835
6228
  "color",
5836
6229
  "icon_name",
@@ -5843,7 +6236,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5843
6236
  "aliases": {
5844
6237
  "packageId": "package_id",
5845
6238
  "packageName": "package_name",
5846
- "createIfMissing": "create_if_missing",
5847
6239
  "iconName": "icon",
5848
6240
  "iconColor": "color",
5849
6241
  "icon_config.name": "icon",
@@ -5860,6 +6252,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5860
6252
  },
5861
6253
  "execution_notes": [
5862
6254
  "create or update package metadata, visibility, grouping, and ordering in one call",
6255
+ "create mode: package_name without package_id creates a package",
5863
6256
  "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
5864
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",
5865
6258
  "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
@@ -5868,6 +6261,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5868
6261
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
5869
6262
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
5870
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",
5871
6265
  "layout apply calls backend package ordering (MoveGroupAuth), so it requires package edit_app permission even when items=[] only clears/deletes existing groups",
5872
6266
  *_VISIBILITY_EXECUTION_NOTES,
5873
6267
  ],
@@ -5887,7 +6281,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5887
6281
  "create_example": {
5888
6282
  "profile": "default",
5889
6283
  "package_name": "项目管理",
5890
- "create_if_missing": True,
5891
6284
  "icon": "briefcase",
5892
6285
  "color": "azure",
5893
6286
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
@@ -6106,6 +6499,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6106
6499
  "remove_buttons supports button_id or exact unique button_text",
6107
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",
6108
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",
6109
6503
  "all operations share one edit context and publish after at least one write succeeds; there is no draft-only mode for this tool",
6110
6504
  "background_color and text_color cannot both be white",
6111
6505
  ],
@@ -6251,6 +6645,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6251
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",
6252
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",
6253
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",
6254
6649
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
6255
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",
6256
6651
  ],
@@ -6285,7 +6680,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6285
6680
  },
6286
6681
  },
6287
6682
  "app_schema_plan": {
6288
- "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"],
6289
6684
  "aliases": {
6290
6685
  "app_title": "app_name",
6291
6686
  "title": "app_name",
@@ -6340,7 +6735,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6340
6735
  "icon": "briefcase",
6341
6736
  "color": "emerald",
6342
6737
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
6343
- "create_if_missing": True,
6344
6738
  "add_fields": [{"name": "项目名称", "type": "text", "data_title": True}],
6345
6739
  "update_fields": [],
6346
6740
  "remove_fields": [],
@@ -6374,7 +6768,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6374
6768
  "icon_color",
6375
6769
  "icon_config",
6376
6770
  "visibility",
6377
- "create_if_missing",
6378
6771
  "publish",
6379
6772
  "form",
6380
6773
  "add_fields",
@@ -6457,13 +6850,14 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6457
6850
  "execution_notes": [
6458
6851
  "use exactly one resource mode",
6459
6852
  "edit mode: app_key, optional app_name to rename the existing app",
6460
- "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",
6461
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",
6462
- "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",
6463
6857
  "CLI --apps-file primary shape is {package_id, apps:[...]}; raw app arrays and singleton wrapper arrays are compatibility paths, not recommended examples",
6464
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",
6465
6859
  "single-app CLI primary shape is --form-file; multi-app primary shape is apps[].form inside --apps-file",
6466
- "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",
6467
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",
6468
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",
6469
6863
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
@@ -6504,7 +6898,6 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
6504
6898
  "icon": "briefcase",
6505
6899
  "color": "emerald",
6506
6900
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
6507
- "create_if_missing": True,
6508
6901
  "publish": True,
6509
6902
  "form": [
6510
6903
  {