@josephyan/qingflow-cli 0.2.0-beta.72 → 0.2.0-beta.74

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.
@@ -379,6 +379,61 @@ class AiBuilderFacade:
379
379
  "tag_icon": tag_icon,
380
380
  }
381
381
 
382
+ def solution_install(
383
+ self,
384
+ *,
385
+ profile: str,
386
+ solution_key: str,
387
+ being_copy_data: bool = True,
388
+ solution_source: str = "solutionDetail",
389
+ ) -> JSONObject:
390
+ requested_solution_key = str(solution_key or "").strip()
391
+ requested_solution_source = str(solution_source or "").strip() or "solutionDetail"
392
+ normalized_args = {
393
+ "solution_key": requested_solution_key,
394
+ "being_copy_data": bool(being_copy_data),
395
+ "solution_source": requested_solution_source,
396
+ }
397
+ if not requested_solution_key:
398
+ return _failed(
399
+ "SOLUTION_KEY_REQUIRED",
400
+ "solution_key is required",
401
+ normalized_args=normalized_args,
402
+ suggested_next_call=None,
403
+ )
404
+ try:
405
+ created = self.apps.app_create(
406
+ profile=profile,
407
+ payload={
408
+ "solutionKey": requested_solution_key,
409
+ "beingCopyData": bool(being_copy_data),
410
+ "solutionSource": requested_solution_source,
411
+ },
412
+ )
413
+ except (QingflowApiError, RuntimeError) as error:
414
+ api_error = _coerce_api_error(error)
415
+ return _failed_from_api_error(
416
+ "SOLUTION_INSTALL_FAILED",
417
+ api_error,
418
+ normalized_args=normalized_args,
419
+ details={"solution_key": requested_solution_key},
420
+ suggested_next_call={"tool_name": "solution_install", "arguments": {"profile": profile, **normalized_args}},
421
+ )
422
+ return {
423
+ "status": "success",
424
+ "error_code": None,
425
+ "recoverable": False,
426
+ "message": "installed solution",
427
+ "normalized_args": normalized_args,
428
+ "missing_fields": [],
429
+ "allowed_values": {},
430
+ "details": {},
431
+ "request_id": created.get("request_id"),
432
+ "suggested_next_call": None,
433
+ "noop": False,
434
+ "verification": {},
435
+ }
436
+
382
437
  def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
383
438
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
384
439
  return {
@@ -2088,6 +2143,280 @@ class AiBuilderFacade:
2088
2143
  result["message"] = "read app field config"
2089
2144
  return result
2090
2145
 
2146
+ def app_repair_code_blocks(
2147
+ self,
2148
+ *,
2149
+ profile: str,
2150
+ app_key: str,
2151
+ field: str | None = None,
2152
+ apply: bool = False,
2153
+ ) -> JSONObject:
2154
+ try:
2155
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
2156
+ except (QingflowApiError, RuntimeError) as error:
2157
+ api_error = _coerce_api_error(error)
2158
+ return _failed_from_api_error(
2159
+ "FIELDS_READ_FAILED",
2160
+ api_error,
2161
+ normalized_args={"app_key": app_key, "field": field, "apply": apply},
2162
+ details={"app_key": app_key},
2163
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2164
+ )
2165
+ parsed_fields = cast(list[dict[str, Any]], state["parsed"].get("fields") or [])
2166
+ field_lookup = _build_public_field_lookup(parsed_fields)
2167
+ selected_fields: list[dict[str, Any]]
2168
+ requested_selector = str(field or "").strip()
2169
+ if requested_selector:
2170
+ try:
2171
+ selected_field = _resolve_public_field(requested_selector, field_lookup=field_lookup)
2172
+ except ValueError:
2173
+ return _failed(
2174
+ "FIELD_NOT_FOUND",
2175
+ "field selector did not match any existing field",
2176
+ normalized_args={"app_key": app_key, "field": requested_selector, "apply": apply},
2177
+ details={"app_key": app_key, "field": requested_selector},
2178
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2179
+ )
2180
+ if str(selected_field.get("type") or "") != FieldType.code_block.value:
2181
+ return _failed(
2182
+ "CODE_BLOCK_FIELD_REQUIRED",
2183
+ "selected field is not a code_block field",
2184
+ normalized_args={"app_key": app_key, "field": requested_selector, "apply": apply},
2185
+ details={"app_key": app_key, "field": requested_selector, "field_type": selected_field.get("type")},
2186
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2187
+ )
2188
+ selected_fields = [selected_field]
2189
+ else:
2190
+ selected_fields = [field_item for field_item in parsed_fields if str(field_item.get("type") or "") == FieldType.code_block.value]
2191
+ plans: list[dict[str, Any]] = []
2192
+ update_fields: list[FieldUpdatePatch] = []
2193
+ applied_fields: list[str] = []
2194
+ for code_block_field in selected_fields:
2195
+ field_name = _code_block_field_display_name(code_block_field)
2196
+ normalized_binding = _normalize_code_block_binding(code_block_field.get("code_block_binding"))
2197
+ normalized_config = _normalize_code_block_config(code_block_field.get("code_block_config") or code_block_field.get("config") or {}) or None
2198
+ raw_binding = code_block_field.get("code_block_binding") if isinstance(code_block_field.get("code_block_binding"), dict) else None
2199
+ preserved_output_targets = [
2200
+ str((output.get("target_field") or {}).get("name") or "").strip()
2201
+ for output in cast(list[dict[str, Any]], (normalized_binding or {}).get("outputs") or [])
2202
+ if isinstance(output.get("target_field"), dict) and str((output.get("target_field") or {}).get("name") or "").strip()
2203
+ ]
2204
+ detected_issues: list[str] = []
2205
+ warnings: list[dict[str, Any]] = []
2206
+ normalized_code_preview: str | None = None
2207
+ normalized_binding_code = None
2208
+ normalized_config_code = None
2209
+ if normalized_binding is not None:
2210
+ raw_code = str(normalized_binding.get("code") or "")
2211
+ normalized_binding_code = _normalize_code_block_output_assignment(raw_code)
2212
+ if normalized_binding_code != raw_code:
2213
+ detected_issues.append("CONST_OR_LET_QF_OUTPUT_ASSIGNMENT")
2214
+ normalized_code_preview = normalized_binding_code
2215
+ if normalized_config is not None:
2216
+ raw_code_content = str(normalized_config.get("code_content") or "")
2217
+ normalized_config_code = _normalize_code_block_output_assignment(raw_code_content)
2218
+ if normalized_code_preview is None and normalized_config_code != raw_code_content:
2219
+ normalized_code_preview = normalized_config_code
2220
+ try:
2221
+ _validate_code_block_alias_config(
2222
+ field_name=field_name,
2223
+ raw_binding=raw_binding,
2224
+ normalized_config=normalized_config,
2225
+ )
2226
+ except _CodeBlockValidationError as error:
2227
+ detected_issues.append(error.error_code)
2228
+ warnings.append(_warning(error.error_code, error.message))
2229
+ has_outputs = bool((normalized_binding or {}).get("outputs")) or bool((normalized_config or {}).get("result_alias_path"))
2230
+ effective_code = ""
2231
+ if normalized_binding is not None:
2232
+ effective_code = str(normalized_binding_code if normalized_binding_code is not None else normalized_binding.get("code") or "")
2233
+ elif normalized_config is not None:
2234
+ code_content = str(normalized_config_code if normalized_config_code is not None else normalized_config.get("code_content") or "")
2235
+ effective_code = _strip_code_block_generated_input_prelude(code_content)
2236
+ if has_outputs and not _code_block_has_effective_output_assignment(effective_code):
2237
+ detected_issues.append("CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING")
2238
+ warnings.append(
2239
+ _warning(
2240
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING",
2241
+ "configured outputs require qf_output assignment before runtime writeback can succeed",
2242
+ )
2243
+ )
2244
+ target_relation_default_verified = True
2245
+ binding_has_target_bindings = False
2246
+ for output in cast(list[dict[str, Any]], (normalized_binding or {}).get("outputs") or []):
2247
+ target_payload = output.get("target_field")
2248
+ if not isinstance(target_payload, dict):
2249
+ continue
2250
+ if any(target_payload.values()):
2251
+ binding_has_target_bindings = True
2252
+ try:
2253
+ target_field = _resolve_field_selector_with_uniqueness(
2254
+ fields=parsed_fields,
2255
+ selector_payload=target_payload,
2256
+ location="repair.target_field",
2257
+ )
2258
+ except ValueError:
2259
+ target_relation_default_verified = False
2260
+ detected_issues.append("CODE_BLOCK_TARGET_DEFAULT_INVALID")
2261
+ warnings.append(_warning("CODE_BLOCK_TARGET_DEFAULT_INVALID", "bound output target could not be resolved during repair scan"))
2262
+ break
2263
+ if _coerce_any_int(target_field.get("default_type")) == DEFAULT_TYPE_RELATION:
2264
+ continue
2265
+ target_relation_default_verified = False
2266
+ detected_issues.append("CODE_BLOCK_TARGET_DEFAULT_INVALID")
2267
+ warnings.append(
2268
+ _warning(
2269
+ "CODE_BLOCK_TARGET_DEFAULT_INVALID",
2270
+ "bound output target is not stored as relation default and should be rebuilt through repair",
2271
+ target_field_name=_code_block_field_display_name(target_field),
2272
+ )
2273
+ )
2274
+ alias_issue_detected = "CODE_BLOCK_ALIAS_REQUIRED" in detected_issues
2275
+ assignment_missing_detected = "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING" in detected_issues
2276
+ can_auto_repair = not alias_issue_detected and not assignment_missing_detected
2277
+ repair_mode = "none"
2278
+ patch_payload: dict[str, Any] | None = None
2279
+ safe_auto_fix = False
2280
+ if can_auto_repair and normalized_binding is not None and binding_has_target_bindings and (
2281
+ (normalized_binding_code is not None and normalized_binding_code != str(normalized_binding.get("code") or ""))
2282
+ or not target_relation_default_verified
2283
+ ):
2284
+ patch_payload = {
2285
+ "selector": _field_selector_payload_for_field(code_block_field),
2286
+ "set": {
2287
+ "code_block_binding": _public_code_block_binding_payload(
2288
+ {**normalized_binding, "code": normalized_binding_code if normalized_binding_code is not None else str(normalized_binding.get("code") or "")}
2289
+ )
2290
+ },
2291
+ }
2292
+ repair_mode = "binding"
2293
+ safe_auto_fix = True
2294
+ elif (
2295
+ can_auto_repair
2296
+ and normalized_config is not None
2297
+ and normalized_config_code is not None
2298
+ and normalized_config_code != str(normalized_config.get("code_content") or "")
2299
+ ):
2300
+ patch_payload = {
2301
+ "selector": _field_selector_payload_for_field(code_block_field),
2302
+ "set": {"code_block_config": {**normalized_config, "code_content": normalized_config_code}},
2303
+ }
2304
+ repair_mode = "config"
2305
+ safe_auto_fix = True
2306
+ if not detected_issues:
2307
+ detected_issues.append("NO_REPAIR_NEEDED")
2308
+ plan = {
2309
+ "field_name": field_name,
2310
+ "field_id": str(code_block_field.get("field_id") or "").strip() or None,
2311
+ "que_id": _coerce_positive_int(code_block_field.get("que_id")),
2312
+ "detected_issues": sorted(set(detected_issues)),
2313
+ "normalized_code_preview": normalized_code_preview,
2314
+ "would_update": bool(patch_payload and safe_auto_fix),
2315
+ "applied": False,
2316
+ "repair_mode": repair_mode,
2317
+ "preserved_output_targets": preserved_output_targets,
2318
+ "warnings": warnings,
2319
+ }
2320
+ if patch_payload and safe_auto_fix:
2321
+ update_fields.append(FieldUpdatePatch.model_validate(patch_payload))
2322
+ plans.append(plan)
2323
+ if not apply:
2324
+ return {
2325
+ "status": "success",
2326
+ "error_code": None,
2327
+ "recoverable": False,
2328
+ "message": "planned code block repair",
2329
+ "normalized_args": {"app_key": app_key, "field": requested_selector or None, "apply": False},
2330
+ "missing_fields": [],
2331
+ "allowed_values": {},
2332
+ "details": {},
2333
+ "request_id": None,
2334
+ "suggested_next_call": None,
2335
+ "noop": not bool(update_fields),
2336
+ "warnings": [],
2337
+ "verification": {
2338
+ "app_exists": True,
2339
+ "code_block_fields_scanned": len(plans),
2340
+ "would_update": bool(update_fields),
2341
+ },
2342
+ "verified": True,
2343
+ "app_key": app_key,
2344
+ "apply": False,
2345
+ "repair_plan": plans,
2346
+ "would_update_fields": [plan["field_name"] for plan in plans if plan["would_update"]],
2347
+ }
2348
+ if not update_fields:
2349
+ return {
2350
+ "status": "success",
2351
+ "error_code": None,
2352
+ "recoverable": False,
2353
+ "message": "no safe code block repairs were required",
2354
+ "normalized_args": {"app_key": app_key, "field": requested_selector or None, "apply": True},
2355
+ "missing_fields": [],
2356
+ "allowed_values": {},
2357
+ "details": {},
2358
+ "request_id": None,
2359
+ "suggested_next_call": None,
2360
+ "noop": True,
2361
+ "warnings": [],
2362
+ "verification": {
2363
+ "app_exists": True,
2364
+ "code_block_fields_scanned": len(plans),
2365
+ "would_update": False,
2366
+ "applied": False,
2367
+ },
2368
+ "verified": True,
2369
+ "app_key": app_key,
2370
+ "apply": True,
2371
+ "repair_plan": plans,
2372
+ "applied_fields": [],
2373
+ }
2374
+ apply_result = self.app_schema_apply(
2375
+ profile=profile,
2376
+ app_key=app_key,
2377
+ package_tag_id=None,
2378
+ app_name="",
2379
+ publish=True,
2380
+ add_fields=[],
2381
+ update_fields=update_fields,
2382
+ remove_fields=[],
2383
+ )
2384
+ if apply_result.get("status") == "failed":
2385
+ return apply_result
2386
+ try:
2387
+ reread = self._load_base_schema_state(profile=profile, app_key=app_key)
2388
+ verified_fields = cast(list[dict[str, Any]], reread["parsed"].get("fields") or [])
2389
+ verified_lookup = _build_public_field_lookup(verified_fields)
2390
+ for plan in plans:
2391
+ selector = plan["field_id"] or plan["field_name"] or plan["que_id"]
2392
+ if selector is None:
2393
+ continue
2394
+ try:
2395
+ verified_field = _resolve_public_field(selector, field_lookup=verified_lookup)
2396
+ except ValueError:
2397
+ continue
2398
+ verified_binding = _normalize_code_block_binding(verified_field.get("code_block_binding"))
2399
+ verified_config = _normalize_code_block_config(verified_field.get("code_block_config") or verified_field.get("config") or {}) or {}
2400
+ verified_code = str((verified_binding or {}).get("code") or "")
2401
+ verified_content = str(verified_config.get("code_content") or "")
2402
+ if "const qf_output =" not in verified_code and "let qf_output =" not in verified_code and "const qf_output =" not in verified_content and "let qf_output =" not in verified_content:
2403
+ if plan["would_update"]:
2404
+ plan["applied"] = True
2405
+ applied_fields.append(plan["field_name"])
2406
+ except (QingflowApiError, RuntimeError):
2407
+ pass
2408
+ apply_result["message"] = "repaired code block fields"
2409
+ apply_result["apply"] = True
2410
+ apply_result["repair_plan"] = plans
2411
+ apply_result["applied_fields"] = applied_fields
2412
+ apply_result["verification"] = {
2413
+ **(apply_result.get("verification") if isinstance(apply_result.get("verification"), dict) else {}),
2414
+ "code_block_fields_scanned": len(plans),
2415
+ "would_update": bool(update_fields),
2416
+ "applied": bool(applied_fields),
2417
+ }
2418
+ return apply_result
2419
+
2091
2420
  def app_get_layout(self, *, profile: str, app_key: str) -> JSONObject:
2092
2421
  result = self.app_read_layout_summary(profile=profile, app_key=app_key)
2093
2422
  if result.get("status") == "success":
@@ -3338,18 +3667,33 @@ class AiBuilderFacade:
3338
3667
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3339
3668
  )
3340
3669
 
3670
+ normalized_code_block_fields: list[str] = []
3341
3671
  try:
3672
+ current_fields, normalized_code_block_fields = _normalize_and_validate_code_block_fields_for_write(fields=current_fields)
3342
3673
  current_fields, compiled_question_relations = _compile_code_block_binding_fields(
3343
3674
  fields=current_fields,
3344
3675
  current_schema=schema_result,
3345
3676
  )
3677
+ _ensure_code_block_targets_compiled_as_relation_defaults(fields=current_fields)
3678
+ except _CodeBlockValidationError as error:
3679
+ return _failed(
3680
+ error.error_code,
3681
+ error.message,
3682
+ normalized_args=normalized_args,
3683
+ details={"app_key": target.app_key, **error.details},
3684
+ suggested_next_call=_code_block_repair_suggested_next_call(
3685
+ profile=profile,
3686
+ app_key=target.app_key,
3687
+ field_name=str(error.details.get("field_name") or "").strip() or None,
3688
+ ),
3689
+ )
3346
3690
  except ValueError as error:
3347
3691
  return _failed(
3348
3692
  "CODE_BLOCK_BINDING_INVALID",
3349
3693
  str(error),
3350
3694
  normalized_args=normalized_args,
3351
3695
  details={"app_key": target.app_key},
3352
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3696
+ suggested_next_call=_code_block_repair_suggested_next_call(profile=profile, app_key=target.app_key),
3353
3697
  )
3354
3698
 
3355
3699
  q_linker_schema_context = deepcopy(schema_result)
@@ -3375,8 +3719,19 @@ class AiBuilderFacade:
3375
3719
  if not relation_limit_verified
3376
3720
  else []
3377
3721
  )
3722
+ code_block_normalization_warnings = (
3723
+ [
3724
+ _warning(
3725
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_NORMALIZED",
3726
+ "normalized code block qf_output assignment before schema write",
3727
+ field_names=normalized_code_block_fields,
3728
+ )
3729
+ ]
3730
+ if normalized_code_block_fields
3731
+ else []
3732
+ )
3378
3733
 
3379
- if not added and not updated and not removed and not bool(resolved.get("created")):
3734
+ if not added and not updated and not removed and not normalized_code_block_fields and not bool(resolved.get("created")):
3380
3735
  tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
3381
3736
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
3382
3737
  response = {
@@ -3391,7 +3746,7 @@ class AiBuilderFacade:
3391
3746
  "request_id": None,
3392
3747
  "suggested_next_call": None if package_attached is not False else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": package_tag_id, "app_key": target.app_key}},
3393
3748
  "noop": not bool(visual_result.get("updated")),
3394
- "warnings": relation_warnings,
3749
+ "warnings": relation_warnings + code_block_normalization_warnings,
3395
3750
  "verification": {
3396
3751
  "fields_verified": True,
3397
3752
  "relation_field_limit_verified": relation_limit_verified,
@@ -3407,6 +3762,9 @@ class AiBuilderFacade:
3407
3762
  "package_attached": package_attached,
3408
3763
  }
3409
3764
  response["details"]["relation_field_count"] = relation_field_count
3765
+ if normalized_code_block_fields:
3766
+ response["normalized_code_block_output_assignment"] = True
3767
+ response["normalized_code_block_fields"] = normalized_code_block_fields
3410
3768
  response = _apply_permission_outcomes(response, relation_permission_outcome)
3411
3769
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
3412
3770
 
@@ -3473,16 +3831,30 @@ class AiBuilderFacade:
3473
3831
  rebound_layout = cast(dict[str, Any], rebound_parsed.get("layout") or {"root_rows": [], "sections": []})
3474
3832
  _overlay_code_block_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3475
3833
  _overlay_q_linker_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3834
+ rebound_fields, _normalized_rebound_code_blocks = _normalize_and_validate_code_block_fields_for_write(fields=rebound_fields)
3476
3835
  rebound_fields, compiled_question_relations = _compile_code_block_binding_fields(
3477
3836
  fields=rebound_fields,
3478
3837
  current_schema=rebound_schema,
3479
3838
  )
3839
+ _ensure_code_block_targets_compiled_as_relation_defaults(fields=rebound_fields)
3480
3840
  rebound_q_linker_schema = deepcopy(rebound_schema)
3481
3841
  rebound_q_linker_schema["questionRelations"] = deepcopy(compiled_question_relations)
3482
3842
  rebound_fields, compiled_question_relations = _compile_q_linker_binding_fields(
3483
3843
  fields=rebound_fields,
3484
3844
  current_schema=rebound_q_linker_schema,
3485
3845
  )
3846
+ except _CodeBlockValidationError as error:
3847
+ return _failed(
3848
+ error.error_code,
3849
+ error.message,
3850
+ normalized_args=normalized_args,
3851
+ details={"app_key": target.app_key, **error.details},
3852
+ suggested_next_call=_code_block_repair_suggested_next_call(
3853
+ profile=profile,
3854
+ app_key=target.app_key,
3855
+ field_name=str(error.details.get("field_name") or "").strip() or None,
3856
+ ),
3857
+ )
3486
3858
  except ValueError as error:
3487
3859
  return _failed(
3488
3860
  "Q_LINKER_BINDING_INVALID" if "q_linker" in str(error) else "CODE_BLOCK_BINDING_INVALID",
@@ -3544,7 +3916,7 @@ class AiBuilderFacade:
3544
3916
  "request_id": None,
3545
3917
  "suggested_next_call": None,
3546
3918
  "noop": False,
3547
- "warnings": relation_warnings,
3919
+ "warnings": relation_warnings + code_block_normalization_warnings,
3548
3920
  "verification": {
3549
3921
  "fields_verified": False,
3550
3922
  "package_attached": None,
@@ -3565,6 +3937,9 @@ class AiBuilderFacade:
3565
3937
  "package_attached": None,
3566
3938
  }
3567
3939
  response["details"]["relation_field_count"] = relation_field_count
3940
+ if normalized_code_block_fields:
3941
+ response["normalized_code_block_output_assignment"] = True
3942
+ response["normalized_code_block_fields"] = normalized_code_block_fields
3568
3943
  if schema_readback_delayed:
3569
3944
  response["verification"]["schema_readback_delayed"] = True
3570
3945
  response = _apply_permission_outcomes(response, relation_permission_outcome)
@@ -8785,6 +9160,7 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
8785
9160
  "q_linker_binding": q_linker_binding,
8786
9161
  "code_block_config": code_block_config,
8787
9162
  "code_block_binding": code_block_binding,
9163
+ "_explicit_code_block_binding": code_block_binding is not None,
8788
9164
  "config": deepcopy(remote_lookup_config) if remote_lookup_config is not None else (deepcopy(code_block_config) if code_block_config is not None else {}),
8789
9165
  "auto_trigger": patch.auto_trigger,
8790
9166
  "custom_button_text_enabled": patch.custom_button_text_enabled,
@@ -9011,6 +9387,7 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
9011
9387
  field["config"] = deepcopy(payload["code_block_config"])
9012
9388
  if "code_block_binding" in payload:
9013
9389
  field["code_block_binding"] = payload["code_block_binding"]
9390
+ field["_explicit_code_block_binding"] = True
9014
9391
  if "auto_trigger" in payload:
9015
9392
  field["auto_trigger"] = payload["auto_trigger"]
9016
9393
  if "custom_button_text_enabled" in payload:
@@ -9087,7 +9464,225 @@ def _strip_code_block_generated_input_prelude(code_content: str) -> str:
9087
9464
 
9088
9465
 
9089
9466
  def _normalize_code_block_output_assignment(code_content: str) -> str:
9090
- return re.sub(r"(?m)^(\s*)(?:const|let)\s+qf_output\s*=", r"\1qf_output =", code_content)
9467
+ return re.sub(r"(?<![A-Za-z0-9_$])(?:const|let)\s+qf_output\s*=", "qf_output =", code_content)
9468
+
9469
+
9470
+ @dataclass
9471
+ class _CodeBlockValidationError(ValueError):
9472
+ error_code: str
9473
+ message: str
9474
+ details: JSONObject = field(default_factory=dict)
9475
+
9476
+ def __str__(self) -> str:
9477
+ return self.message
9478
+
9479
+
9480
+ def _code_block_has_effective_output_assignment(code_content: str) -> bool:
9481
+ return bool(re.search(r"(?<![A-Za-z0-9_$])qf_output\s*=", str(code_content or "")))
9482
+
9483
+
9484
+ def _field_selector_payload_for_field(field: dict[str, Any]) -> dict[str, Any]:
9485
+ payload: dict[str, Any] = {}
9486
+ field_id = str(field.get("field_id") or "").strip()
9487
+ que_id = _coerce_positive_int(field.get("que_id"))
9488
+ name = str(field.get("name") or "").strip()
9489
+ if field_id:
9490
+ payload["field_id"] = field_id
9491
+ elif que_id is not None:
9492
+ payload["que_id"] = que_id
9493
+ elif name:
9494
+ payload["name"] = name
9495
+ return payload
9496
+
9497
+
9498
+ def _field_selector_payload_for_selector(value: Any) -> dict[str, Any]:
9499
+ if isinstance(value, dict):
9500
+ payload = {
9501
+ "field_id": str(value.get("field_id") or "").strip() or None,
9502
+ "que_id": _coerce_positive_int(value.get("que_id")),
9503
+ "name": str(value.get("name") or "").strip() or None,
9504
+ }
9505
+ return {key: item for key, item in payload.items() if item is not None}
9506
+ if isinstance(value, str) and value.strip():
9507
+ return {"name": value.strip()}
9508
+ return {}
9509
+
9510
+
9511
+ def _code_block_field_display_name(field: dict[str, Any]) -> str:
9512
+ name = str(field.get("name") or "").strip()
9513
+ if name:
9514
+ return name
9515
+ field_id = str(field.get("field_id") or "").strip()
9516
+ if field_id:
9517
+ return field_id
9518
+ que_id = _coerce_positive_int(field.get("que_id"))
9519
+ if que_id is not None:
9520
+ return str(que_id)
9521
+ return "未命名代码块"
9522
+
9523
+
9524
+ def _public_code_block_binding_payload(binding: dict[str, Any]) -> dict[str, Any]:
9525
+ return {
9526
+ "inputs": [
9527
+ {
9528
+ "field": _field_selector_payload_for_selector(input_item.get("field")),
9529
+ "var": input_item.get("var"),
9530
+ }
9531
+ for input_item in cast(list[dict[str, Any]], binding.get("inputs") or [])
9532
+ if _field_selector_payload_for_selector(input_item.get("field"))
9533
+ ],
9534
+ "code": str(binding.get("code") or ""),
9535
+ "auto_trigger": binding.get("auto_trigger"),
9536
+ "custom_button_text_enabled": binding.get("custom_button_text_enabled"),
9537
+ "custom_button_text": binding.get("custom_button_text"),
9538
+ "outputs": [
9539
+ {
9540
+ "alias": str(output.get("alias") or "").strip(),
9541
+ "path": str(output.get("path") or "").strip(),
9542
+ "target_field": _field_selector_payload_for_selector(output.get("target_field")),
9543
+ }
9544
+ for output in cast(list[dict[str, Any]], binding.get("outputs") or [])
9545
+ if str(output.get("alias") or "").strip()
9546
+ and str(output.get("path") or "").strip()
9547
+ and _field_selector_payload_for_selector(output.get("target_field"))
9548
+ ],
9549
+ }
9550
+
9551
+
9552
+ def _raw_field_selector_present(value: Any) -> bool:
9553
+ if isinstance(value, str):
9554
+ return bool(value.strip())
9555
+ if not isinstance(value, dict):
9556
+ return False
9557
+ return bool(
9558
+ str(value.get("field_id") or "").strip()
9559
+ or _coerce_positive_int(value.get("que_id"))
9560
+ or str(value.get("name") or value.get("title") or value.get("label") or "").strip()
9561
+ )
9562
+
9563
+
9564
+ def _validate_code_block_alias_config(
9565
+ *,
9566
+ field_name: str,
9567
+ raw_binding: dict[str, Any] | None,
9568
+ normalized_config: dict[str, Any] | None,
9569
+ ) -> None:
9570
+ raw_outputs = raw_binding.get("outputs") if isinstance(raw_binding, dict) and isinstance(raw_binding.get("outputs"), list) else []
9571
+ for index, raw_output in enumerate(cast(list[Any], raw_outputs)):
9572
+ if not isinstance(raw_output, dict):
9573
+ continue
9574
+ raw_target = raw_output.get("target_field", raw_output.get("targetField"))
9575
+ if not _raw_field_selector_present(raw_target):
9576
+ continue
9577
+ alias = str(raw_output.get("alias", raw_output.get("alias_name", raw_output.get("aliasName", ""))) or "").strip()
9578
+ path = str(raw_output.get("path", raw_output.get("alias_path", raw_output.get("aliasPath", ""))) or "").strip()
9579
+ if alias and path:
9580
+ continue
9581
+ raise _CodeBlockValidationError(
9582
+ "CODE_BLOCK_ALIAS_REQUIRED",
9583
+ f"code_block field '{field_name}' requires non-empty alias and path for output target binding",
9584
+ details={"field_name": field_name, "output_index": index},
9585
+ )
9586
+ normalized_aliases = cast(list[dict[str, Any]], (normalized_config or {}).get("result_alias_path") or [])
9587
+ for index, alias_item in enumerate(normalized_aliases):
9588
+ alias_name = str(alias_item.get("alias_name") or "").strip()
9589
+ alias_path = str(alias_item.get("alias_path") or "").strip()
9590
+ if alias_name and alias_path:
9591
+ continue
9592
+ raise _CodeBlockValidationError(
9593
+ "CODE_BLOCK_ALIAS_REQUIRED",
9594
+ f"code_block field '{field_name}' contains an empty output alias configuration",
9595
+ details={"field_name": field_name, "alias_index": index},
9596
+ )
9597
+
9598
+
9599
+ def _normalize_and_validate_code_block_fields_for_write(
9600
+ *,
9601
+ fields: list[dict[str, Any]],
9602
+ ) -> tuple[list[dict[str, Any]], list[str]]:
9603
+ next_fields = deepcopy(fields)
9604
+ normalized_fields: list[str] = []
9605
+ for field in next_fields:
9606
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
9607
+ continue
9608
+ field_name = _code_block_field_display_name(field)
9609
+ raw_binding = field.get("code_block_binding") if isinstance(field.get("code_block_binding"), dict) else None
9610
+ binding = _normalize_code_block_binding(raw_binding)
9611
+ current_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or None
9612
+ _validate_code_block_alias_config(field_name=field_name, raw_binding=raw_binding, normalized_config=current_config)
9613
+ normalized = False
9614
+ if binding is not None:
9615
+ raw_code = str(binding.get("code") or "")
9616
+ normalized_code = _normalize_code_block_output_assignment(raw_code)
9617
+ if normalized_code != raw_code:
9618
+ normalized = True
9619
+ binding["code"] = normalized_code
9620
+ field["code_block_binding"] = binding
9621
+ if current_config is not None:
9622
+ raw_code_content = str(current_config.get("code_content") or "")
9623
+ normalized_code_content = _normalize_code_block_output_assignment(raw_code_content)
9624
+ if normalized_code_content != raw_code_content:
9625
+ normalized = True
9626
+ current_config["code_content"] = normalized_code_content
9627
+ field["code_block_config"] = current_config
9628
+ field["config"] = deepcopy(current_config)
9629
+ conflicting_binding_and_config = False
9630
+ if binding is not None and current_config is not None:
9631
+ current_config_code = str(current_config.get("code_content") or "")
9632
+ compiled_body = _strip_code_block_generated_input_prelude(current_config_code)
9633
+ if current_config_code.strip() and compiled_body != str(binding.get("code") or ""):
9634
+ conflicting_binding_and_config = True
9635
+ has_outputs = bool((binding or {}).get("outputs")) or bool((current_config or {}).get("result_alias_path"))
9636
+ effective_code = ""
9637
+ if binding is not None:
9638
+ effective_code = str(binding.get("code") or "")
9639
+ elif current_config is not None:
9640
+ effective_code = _strip_code_block_generated_input_prelude(str(current_config.get("code_content") or ""))
9641
+ if has_outputs and not conflicting_binding_and_config and not _code_block_has_effective_output_assignment(effective_code):
9642
+ raise _CodeBlockValidationError(
9643
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING",
9644
+ f"code_block field '{field_name}' must assign to qf_output when outputs are configured",
9645
+ details={"field_name": field_name},
9646
+ )
9647
+ if normalized:
9648
+ normalized_fields.append(field_name)
9649
+ return next_fields, sorted(set(normalized_fields))
9650
+
9651
+
9652
+ def _ensure_code_block_targets_compiled_as_relation_defaults(*, fields: list[dict[str, Any]]) -> None:
9653
+ for field in fields:
9654
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
9655
+ continue
9656
+ field_name = _code_block_field_display_name(field)
9657
+ binding = _normalize_code_block_binding(field.get("code_block_binding"))
9658
+ if binding is None:
9659
+ continue
9660
+ for output_index, output in enumerate(cast(list[dict[str, Any]], binding.get("outputs") or [])):
9661
+ target_payload = output.get("target_field")
9662
+ if not isinstance(target_payload, dict):
9663
+ continue
9664
+ target_field = _resolve_field_selector_with_uniqueness(
9665
+ fields=fields,
9666
+ selector_payload=target_payload,
9667
+ location=f"code_block_binding.outputs[{output_index}].target_field",
9668
+ )
9669
+ if _coerce_any_int(target_field.get("default_type")) == DEFAULT_TYPE_RELATION:
9670
+ continue
9671
+ raise _CodeBlockValidationError(
9672
+ "CODE_BLOCK_TARGET_DEFAULT_INVALID",
9673
+ f"code_block field '{field_name}' output target '{_code_block_field_display_name(target_field)}' is not compiled as relation default",
9674
+ details={
9675
+ "field_name": field_name,
9676
+ "target_field_name": _code_block_field_display_name(target_field),
9677
+ },
9678
+ )
9679
+
9680
+
9681
+ def _code_block_repair_suggested_next_call(*, profile: str, app_key: str, field_name: str | None = None) -> JSONObject:
9682
+ arguments: JSONObject = {"profile": profile, "app_key": app_key}
9683
+ if field_name:
9684
+ arguments["field"] = field_name
9685
+ return {"tool_name": "app_repair_code_blocks", "arguments": arguments}
9091
9686
 
9092
9687
 
9093
9688
  def _ensure_field_temp_ids(fields: list[dict[str, Any]]) -> None:
@@ -9122,12 +9717,21 @@ def _compile_code_block_binding_fields(
9122
9717
  binding = _normalize_code_block_binding(field.get("code_block_binding"))
9123
9718
  if binding is None:
9124
9719
  continue
9720
+ explicit_binding = bool(field.get("_explicit_code_block_binding"))
9721
+ binding_outputs = cast(list[dict[str, Any]], binding.get("outputs") or [])
9722
+ binding_has_target_bindings = any(
9723
+ isinstance(output.get("target_field"), dict) and any(cast(dict[str, Any], output.get("target_field") or {}).values())
9724
+ for output in binding_outputs
9725
+ )
9125
9726
  current_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
9126
9727
  "config_mode": 1,
9127
9728
  "code_content": "",
9128
9729
  "being_hide_on_form": False,
9129
9730
  "result_alias_path": [],
9130
9731
  }
9732
+ if binding_outputs and not binding_has_target_bindings and not explicit_binding:
9733
+ field["code_block_binding"] = None
9734
+ continue
9131
9735
  low_level_code = _strip_code_block_generated_input_prelude(str(current_config.get("code_content") or ""))
9132
9736
  if str(current_config.get("code_content") or "").strip() and low_level_code != str(binding.get("code") or ""):
9133
9737
  raise ValueError(f"code_block field '{field.get('name')}' has conflicting code_block_config.code_content and code_block_binding.code")