@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +41 -2
- package/src/qingflow_mcp/builder_facade/service.py +1261 -141
- package/src/qingflow_mcp/cli/commands/app.py +3 -16
- package/src/qingflow_mcp/cli/commands/builder.py +28 -2
- package/src/qingflow_mcp/cli/commands/record.py +16 -1
- package/src/qingflow_mcp/cli/formatters.py +32 -1
- package/src/qingflow_mcp/public_surface.py +3 -1
- package/src/qingflow_mcp/response_trim.py +55 -3
- package/src/qingflow_mcp/server.py +10 -9
- package/src/qingflow_mcp/server_app_builder.py +26 -5
- package/src/qingflow_mcp/server_app_user.py +12 -15
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +461 -54
- package/src/qingflow_mcp/tools/app_tools.py +53 -8
- package/src/qingflow_mcp/tools/package_tools.py +16 -2
- package/src/qingflow_mcp/tools/record_tools.py +1262 -103
- package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
|
@@ -45,6 +45,11 @@ RECORD_ACCESS_UNBOUNDED_ROW_THRESHOLD = 50_000
|
|
|
45
45
|
RECORD_ACCESS_TIME_BUDGET_SECONDS = 55.0
|
|
46
46
|
RECORD_ACCESS_MIN_REMAINING_SECONDS = 8.0
|
|
47
47
|
RECORD_GET_DETAIL_LOG_PAGE_SIZE = 10
|
|
48
|
+
RECORD_LOGS_PAGE_SIZE = 200
|
|
49
|
+
RECORD_LOGS_PREVIEW_LIMIT = 10
|
|
50
|
+
RECORD_LOGS_MAX_ITEMS = 20_000
|
|
51
|
+
RECORD_LOGS_TIME_BUDGET_SECONDS = 55.0
|
|
52
|
+
RECORD_LOGS_MIN_REMAINING_SECONDS = 8.0
|
|
48
53
|
RECORD_GET_MEDIA_MAX_IMAGES = 30
|
|
49
54
|
RECORD_GET_MEDIA_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
50
55
|
RECORD_GET_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
@@ -269,6 +274,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
|
|
|
269
274
|
FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
|
|
270
275
|
|
|
271
276
|
|
|
277
|
+
def _pick_route_payload(payload: JSONObject) -> JSONObject:
|
|
278
|
+
return {
|
|
279
|
+
key: payload[key]
|
|
280
|
+
for key in (
|
|
281
|
+
"route_type",
|
|
282
|
+
"endpoint_kind",
|
|
283
|
+
"status",
|
|
284
|
+
"role",
|
|
285
|
+
"task_id",
|
|
286
|
+
"workflow_node_id",
|
|
287
|
+
"view_id",
|
|
288
|
+
"view_key",
|
|
289
|
+
"view_name",
|
|
290
|
+
"error_code",
|
|
291
|
+
"backend_code",
|
|
292
|
+
"http_status",
|
|
293
|
+
"request_id",
|
|
294
|
+
"message",
|
|
295
|
+
"reason",
|
|
296
|
+
)
|
|
297
|
+
if key in payload and payload[key] not in (None, "", [], {})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
272
301
|
class RecordTools(ToolBase):
|
|
273
302
|
"""记录工具(中文名:记录读写与分析)。
|
|
274
303
|
|
|
@@ -442,6 +471,20 @@ class RecordTools(ToolBase):
|
|
|
442
471
|
output_profile=output_profile,
|
|
443
472
|
)
|
|
444
473
|
|
|
474
|
+
@mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. This tool hides pagination and returns file paths plus completeness metadata.")
|
|
475
|
+
def record_logs_get(
|
|
476
|
+
profile: str = DEFAULT_PROFILE,
|
|
477
|
+
app_key: str = "",
|
|
478
|
+
record_id: str = "",
|
|
479
|
+
view_id: str | None = None,
|
|
480
|
+
) -> JSONObject:
|
|
481
|
+
return self.record_logs_get(
|
|
482
|
+
profile=profile,
|
|
483
|
+
app_key=app_key,
|
|
484
|
+
record_id=record_id,
|
|
485
|
+
view_id=view_id,
|
|
486
|
+
)
|
|
487
|
+
|
|
445
488
|
@mcp.tool()
|
|
446
489
|
def record_browse_schema_get(
|
|
447
490
|
app_key: str = "",
|
|
@@ -493,8 +536,9 @@ class RecordTools(ToolBase):
|
|
|
493
536
|
@mcp.tool(
|
|
494
537
|
description=(
|
|
495
538
|
"Update one Qingflow record using a field map. "
|
|
496
|
-
"
|
|
497
|
-
"
|
|
539
|
+
"For simple field changes, call this tool directly after the target record is clear. "
|
|
540
|
+
"It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
|
|
541
|
+
"Use record_update_schema_get for diagnostics or complex field-scope inspection."
|
|
498
542
|
)
|
|
499
543
|
)
|
|
500
544
|
def record_update(
|
|
@@ -995,15 +1039,59 @@ class RecordTools(ToolBase):
|
|
|
995
1039
|
item["title"]: self._ready_schema_template_value(item)
|
|
996
1040
|
for item in writable_fields
|
|
997
1041
|
},
|
|
1042
|
+
"available_update_routes": self._record_update_schema_available_routes(matched_probes),
|
|
1043
|
+
"recommended_update_route": {
|
|
1044
|
+
"route_type": "auto",
|
|
1045
|
+
"order": ["admin_direct", "view_edit", "task_save_only"],
|
|
1046
|
+
"message": "record_update will try data-manager direct edit first, then a matching custom-view edit route, then a unique current-user todo save-only route when the target fields are editable on that workflow node.",
|
|
1047
|
+
},
|
|
998
1048
|
}
|
|
999
1049
|
if normalized_output_profile == "verbose":
|
|
1000
1050
|
response["view_probe_summary"] = probe_summary
|
|
1001
1051
|
response["record_context_probe"] = probe_summary
|
|
1002
1052
|
response["ambiguous_fields"] = ambiguous_fields
|
|
1053
|
+
response["route_probe_summary"] = probe_summary
|
|
1003
1054
|
return response
|
|
1004
1055
|
|
|
1005
1056
|
return self._run_record_tool(profile, runner)
|
|
1006
1057
|
|
|
1058
|
+
def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
|
|
1059
|
+
routes: list[JSONObject] = [
|
|
1060
|
+
{
|
|
1061
|
+
"route_type": "admin_direct",
|
|
1062
|
+
"endpoint_kind": "app_apply_update",
|
|
1063
|
+
"role": 1,
|
|
1064
|
+
"availability": "attempted_on_update",
|
|
1065
|
+
"message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
|
|
1066
|
+
}
|
|
1067
|
+
]
|
|
1068
|
+
for probe in matched_probes:
|
|
1069
|
+
if probe.route.kind != "custom":
|
|
1070
|
+
continue
|
|
1071
|
+
view_key = self._route_view_key(probe.route)
|
|
1072
|
+
if not view_key:
|
|
1073
|
+
continue
|
|
1074
|
+
routes.append(
|
|
1075
|
+
{
|
|
1076
|
+
"route_type": "view_edit",
|
|
1077
|
+
"endpoint_kind": "view_apply_update",
|
|
1078
|
+
"view_id": probe.route.view_id,
|
|
1079
|
+
"view_key": view_key,
|
|
1080
|
+
"view_name": probe.route.name,
|
|
1081
|
+
"availability": "candidate",
|
|
1082
|
+
"message": "Uses the same custom-view detail edit route as the frontend.",
|
|
1083
|
+
}
|
|
1084
|
+
)
|
|
1085
|
+
routes.append(
|
|
1086
|
+
{
|
|
1087
|
+
"route_type": "task_save_only",
|
|
1088
|
+
"endpoint_kind": "workflow_node_save_only",
|
|
1089
|
+
"availability": "auto_probe_on_update",
|
|
1090
|
+
"message": "record_update probes the current user's todo list and uses save-only only when exactly one matching task exists and the requested fields are editable on that workflow node.",
|
|
1091
|
+
}
|
|
1092
|
+
)
|
|
1093
|
+
return routes
|
|
1094
|
+
|
|
1007
1095
|
def _record_update_schema_blocked_response(
|
|
1008
1096
|
self,
|
|
1009
1097
|
*,
|
|
@@ -2175,6 +2263,130 @@ class RecordTools(ToolBase):
|
|
|
2175
2263
|
|
|
2176
2264
|
return self._run_record_tool(profile, runner)
|
|
2177
2265
|
|
|
2266
|
+
@tool_cn_name("记录全量日志")
|
|
2267
|
+
def record_logs_get(
|
|
2268
|
+
self,
|
|
2269
|
+
*,
|
|
2270
|
+
profile: str,
|
|
2271
|
+
app_key: str,
|
|
2272
|
+
record_id: Any,
|
|
2273
|
+
view_id: str | None = None,
|
|
2274
|
+
) -> JSONObject:
|
|
2275
|
+
"""读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
|
|
2276
|
+
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
2277
|
+
|
|
2278
|
+
def runner(session_profile, context):
|
|
2279
|
+
resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
|
|
2280
|
+
profile,
|
|
2281
|
+
context,
|
|
2282
|
+
app_key,
|
|
2283
|
+
view_id=view_id,
|
|
2284
|
+
list_type=None,
|
|
2285
|
+
view_key=None,
|
|
2286
|
+
view_name=None,
|
|
2287
|
+
allow_default=True,
|
|
2288
|
+
)
|
|
2289
|
+
warnings: list[JSONObject] = []
|
|
2290
|
+
warnings.extend(compatibility_warnings)
|
|
2291
|
+
warnings.extend(_view_filter_trust_warnings(resolved_view))
|
|
2292
|
+
unavailable_context: list[JSONObject] = []
|
|
2293
|
+
|
|
2294
|
+
schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
|
|
2295
|
+
index = _build_top_level_field_index(schema)
|
|
2296
|
+
audit_info = self._record_get_audit_info(
|
|
2297
|
+
context,
|
|
2298
|
+
app_key=app_key,
|
|
2299
|
+
record_id=record_id_int,
|
|
2300
|
+
resolved_view=resolved_view,
|
|
2301
|
+
)
|
|
2302
|
+
audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
|
|
2303
|
+
detail_result, used_list_type, used_role = self._record_get_apply_detail(
|
|
2304
|
+
context,
|
|
2305
|
+
app_key=app_key,
|
|
2306
|
+
record_id=record_id_int,
|
|
2307
|
+
resolved_view=resolved_view,
|
|
2308
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2309
|
+
)
|
|
2310
|
+
answer_list = _record_detail_answers(detail_result)
|
|
2311
|
+
selected_fields = list(index.by_id.values())
|
|
2312
|
+
fields = [
|
|
2313
|
+
_record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
|
|
2314
|
+
for field in selected_fields
|
|
2315
|
+
]
|
|
2316
|
+
app_name = self._record_get_detail_app_name(
|
|
2317
|
+
profile,
|
|
2318
|
+
context,
|
|
2319
|
+
app_key=app_key,
|
|
2320
|
+
schema=schema,
|
|
2321
|
+
used_list_type=used_list_type,
|
|
2322
|
+
)
|
|
2323
|
+
view_payload = _accessible_view_payload(resolved_view)
|
|
2324
|
+
record_payload = _record_detail_record_payload(
|
|
2325
|
+
app_key=app_key,
|
|
2326
|
+
record_id=record_id_int,
|
|
2327
|
+
detail=detail_result,
|
|
2328
|
+
answer_list=cast(list[JSONValue], answer_list),
|
|
2329
|
+
fields=fields,
|
|
2330
|
+
)
|
|
2331
|
+
log_visibility = self._record_get_log_visibility_context(
|
|
2332
|
+
context,
|
|
2333
|
+
app_key=app_key,
|
|
2334
|
+
record_id=record_id_int,
|
|
2335
|
+
resolved_view=resolved_view,
|
|
2336
|
+
role=used_role,
|
|
2337
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2338
|
+
unavailable_context=unavailable_context,
|
|
2339
|
+
)
|
|
2340
|
+
run_dir = _record_logs_run_dir()
|
|
2341
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
2342
|
+
deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
|
|
2343
|
+
data_logs = self._record_get_full_data_logs_context(
|
|
2344
|
+
context,
|
|
2345
|
+
app_key=app_key,
|
|
2346
|
+
record_id=record_id_int,
|
|
2347
|
+
role=used_role,
|
|
2348
|
+
log_visibility=log_visibility,
|
|
2349
|
+
unavailable_context=unavailable_context,
|
|
2350
|
+
run_dir=run_dir,
|
|
2351
|
+
deadline=deadline,
|
|
2352
|
+
)
|
|
2353
|
+
workflow_logs = self._record_get_full_workflow_logs_context(
|
|
2354
|
+
context,
|
|
2355
|
+
app_key=app_key,
|
|
2356
|
+
record_id=record_id_int,
|
|
2357
|
+
resolved_view=resolved_view,
|
|
2358
|
+
role=used_role,
|
|
2359
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2360
|
+
log_visibility=log_visibility,
|
|
2361
|
+
unavailable_context=unavailable_context,
|
|
2362
|
+
run_dir=run_dir,
|
|
2363
|
+
deadline=deadline,
|
|
2364
|
+
)
|
|
2365
|
+
status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
|
|
2366
|
+
context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
|
|
2367
|
+
payload: JSONObject = {
|
|
2368
|
+
"ok": True,
|
|
2369
|
+
"status": status,
|
|
2370
|
+
"output_profile": "record_logs",
|
|
2371
|
+
"app": {"app_key": app_key, "app_name": app_name},
|
|
2372
|
+
"view": view_payload,
|
|
2373
|
+
"record": record_payload,
|
|
2374
|
+
"local_dir": str(run_dir),
|
|
2375
|
+
"data_logs": data_logs,
|
|
2376
|
+
"workflow_logs": workflow_logs,
|
|
2377
|
+
"warnings": warnings,
|
|
2378
|
+
"unavailable_context": unavailable_context,
|
|
2379
|
+
"context_integrity": context_integrity,
|
|
2380
|
+
}
|
|
2381
|
+
summary_path = run_dir / "summary.json"
|
|
2382
|
+
summary_payload = deepcopy(payload)
|
|
2383
|
+
summary_payload.pop("request_route", None)
|
|
2384
|
+
summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
2385
|
+
payload["summary_path"] = str(summary_path)
|
|
2386
|
+
return payload
|
|
2387
|
+
|
|
2388
|
+
return self._run_record_tool(profile, runner)
|
|
2389
|
+
|
|
2178
2390
|
def _record_get_detail_context(
|
|
2179
2391
|
self,
|
|
2180
2392
|
*,
|
|
@@ -2824,6 +3036,108 @@ class RecordTools(ToolBase):
|
|
|
2824
3036
|
unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
|
|
2825
3037
|
return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
|
|
2826
3038
|
|
|
3039
|
+
def _record_get_full_data_logs_context(
|
|
3040
|
+
self,
|
|
3041
|
+
context, # type: ignore[no-untyped-def]
|
|
3042
|
+
*,
|
|
3043
|
+
app_key: str,
|
|
3044
|
+
record_id: int,
|
|
3045
|
+
role: int,
|
|
3046
|
+
log_visibility: JSONObject,
|
|
3047
|
+
unavailable_context: list[JSONObject],
|
|
3048
|
+
run_dir: Path,
|
|
3049
|
+
deadline: float,
|
|
3050
|
+
) -> JSONObject:
|
|
3051
|
+
"""读取全量数据日志并写入 JSONL。"""
|
|
3052
|
+
if log_visibility.get("status") == "unavailable":
|
|
3053
|
+
return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
|
|
3054
|
+
if log_visibility.get("data_log_visible") is False:
|
|
3055
|
+
return _record_logs_hidden_payload("data_logs")
|
|
3056
|
+
|
|
3057
|
+
def fetch_page(page_num: int) -> JSONValue:
|
|
3058
|
+
return self.backend.request(
|
|
3059
|
+
"POST",
|
|
3060
|
+
context,
|
|
3061
|
+
f"/worksheet/data/log/{app_key}/{record_id}/page",
|
|
3062
|
+
json_body={
|
|
3063
|
+
"viewChannel": log_visibility.get("channel"),
|
|
3064
|
+
"role": role,
|
|
3065
|
+
"pageNum": page_num,
|
|
3066
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3067
|
+
},
|
|
3068
|
+
)
|
|
3069
|
+
|
|
3070
|
+
try:
|
|
3071
|
+
return _record_logs_fetch_all_to_jsonl(
|
|
3072
|
+
fetch_page=fetch_page,
|
|
3073
|
+
normalizer=_record_detail_data_log_item,
|
|
3074
|
+
source="data_logs",
|
|
3075
|
+
file_path=run_dir / "data-logs.jsonl",
|
|
3076
|
+
deadline=deadline,
|
|
3077
|
+
)
|
|
3078
|
+
except QingflowApiError as exc:
|
|
3079
|
+
unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
|
|
3080
|
+
return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
|
|
3081
|
+
|
|
3082
|
+
def _record_get_full_workflow_logs_context(
|
|
3083
|
+
self,
|
|
3084
|
+
context, # type: ignore[no-untyped-def]
|
|
3085
|
+
*,
|
|
3086
|
+
app_key: str,
|
|
3087
|
+
record_id: int,
|
|
3088
|
+
resolved_view: AccessibleViewRoute,
|
|
3089
|
+
role: int,
|
|
3090
|
+
audit_node_id: int | None,
|
|
3091
|
+
log_visibility: JSONObject,
|
|
3092
|
+
unavailable_context: list[JSONObject],
|
|
3093
|
+
run_dir: Path,
|
|
3094
|
+
deadline: float,
|
|
3095
|
+
) -> JSONObject:
|
|
3096
|
+
"""读取全量流程日志并写入 JSONL。"""
|
|
3097
|
+
if log_visibility.get("status") == "unavailable":
|
|
3098
|
+
return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
|
|
3099
|
+
if log_visibility.get("workflow_log_visible") is False:
|
|
3100
|
+
return _record_logs_hidden_payload("workflow_logs")
|
|
3101
|
+
|
|
3102
|
+
def fetch_page(page_num: int) -> JSONValue:
|
|
3103
|
+
if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
3104
|
+
return self.backend.request(
|
|
3105
|
+
"POST",
|
|
3106
|
+
context,
|
|
3107
|
+
f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
|
|
3108
|
+
json_body={
|
|
3109
|
+
"key": resolved_view.view_selection.view_key,
|
|
3110
|
+
"rowRecordId": str(record_id),
|
|
3111
|
+
"pageNum": page_num,
|
|
3112
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3113
|
+
},
|
|
3114
|
+
)
|
|
3115
|
+
return self.backend.request(
|
|
3116
|
+
"POST",
|
|
3117
|
+
context,
|
|
3118
|
+
"/application/workflow/node/record",
|
|
3119
|
+
json_body={
|
|
3120
|
+
"key": app_key,
|
|
3121
|
+
"rowRecordId": str(record_id),
|
|
3122
|
+
"nodeId": audit_node_id,
|
|
3123
|
+
"role": role,
|
|
3124
|
+
"pageNum": page_num,
|
|
3125
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3126
|
+
},
|
|
3127
|
+
)
|
|
3128
|
+
|
|
3129
|
+
try:
|
|
3130
|
+
return _record_logs_fetch_all_to_jsonl(
|
|
3131
|
+
fetch_page=fetch_page,
|
|
3132
|
+
normalizer=_record_detail_workflow_log_item,
|
|
3133
|
+
source="workflow_logs",
|
|
3134
|
+
file_path=run_dir / "workflow-logs.jsonl",
|
|
3135
|
+
deadline=deadline,
|
|
3136
|
+
)
|
|
3137
|
+
except QingflowApiError as exc:
|
|
3138
|
+
unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
|
|
3139
|
+
return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
|
|
3140
|
+
|
|
2827
3141
|
def _record_get_associated_resources(
|
|
2828
3142
|
self,
|
|
2829
3143
|
context, # type: ignore[no-untyped-def]
|
|
@@ -3485,116 +3799,765 @@ class RecordTools(ToolBase):
|
|
|
3485
3799
|
def record_update_public(
|
|
3486
3800
|
self,
|
|
3487
3801
|
*,
|
|
3488
|
-
profile: str = DEFAULT_PROFILE,
|
|
3802
|
+
profile: str = DEFAULT_PROFILE,
|
|
3803
|
+
app_key: str,
|
|
3804
|
+
record_id: Any | None,
|
|
3805
|
+
fields: JSONObject | None = None,
|
|
3806
|
+
items: list[JSONObject] | None = None,
|
|
3807
|
+
dry_run: bool = False,
|
|
3808
|
+
verify_write: bool = True,
|
|
3809
|
+
output_profile: str = "normal",
|
|
3810
|
+
) -> JSONObject:
|
|
3811
|
+
"""执行记录相关逻辑。"""
|
|
3812
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
3813
|
+
if not app_key:
|
|
3814
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3815
|
+
if items is not None:
|
|
3816
|
+
if dry_run not in {True, False}:
|
|
3817
|
+
raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
|
|
3818
|
+
normalized_items = self._normalize_public_record_update_batch_items(
|
|
3819
|
+
record_id=record_id,
|
|
3820
|
+
fields=fields,
|
|
3821
|
+
items=items,
|
|
3822
|
+
)
|
|
3823
|
+
return self._record_update_public_batch(
|
|
3824
|
+
profile=profile,
|
|
3825
|
+
app_key=app_key,
|
|
3826
|
+
items=normalized_items,
|
|
3827
|
+
dry_run=dry_run,
|
|
3828
|
+
verify_write=verify_write,
|
|
3829
|
+
output_profile=normalized_output_profile,
|
|
3830
|
+
)
|
|
3831
|
+
if dry_run:
|
|
3832
|
+
raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
|
|
3833
|
+
if record_id is None:
|
|
3834
|
+
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
3835
|
+
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
3836
|
+
if fields is not None and not isinstance(fields, dict):
|
|
3837
|
+
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3838
|
+
return self._record_update_public_single(
|
|
3839
|
+
profile=profile,
|
|
3840
|
+
app_key=app_key,
|
|
3841
|
+
record_id=record_id_int,
|
|
3842
|
+
fields=cast(JSONObject, fields or {}),
|
|
3843
|
+
verify_write=verify_write,
|
|
3844
|
+
output_profile=normalized_output_profile,
|
|
3845
|
+
)
|
|
3846
|
+
|
|
3847
|
+
def _record_update_public_single(
|
|
3848
|
+
self,
|
|
3849
|
+
*,
|
|
3850
|
+
profile: str,
|
|
3851
|
+
app_key: str,
|
|
3852
|
+
record_id: int,
|
|
3853
|
+
fields: JSONObject,
|
|
3854
|
+
verify_write: bool,
|
|
3855
|
+
output_profile: str,
|
|
3856
|
+
) -> JSONObject:
|
|
3857
|
+
"""执行内部辅助逻辑。"""
|
|
3858
|
+
raw_preflight = self._preflight_record_update_with_auto_view(
|
|
3859
|
+
profile=profile,
|
|
3860
|
+
app_key=app_key,
|
|
3861
|
+
record_id=record_id,
|
|
3862
|
+
fields=fields,
|
|
3863
|
+
force_refresh_form=False,
|
|
3864
|
+
)
|
|
3865
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3866
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3867
|
+
normalized_payload = self._record_write_normalized_payload(
|
|
3868
|
+
operation="update",
|
|
3869
|
+
record_id=record_id,
|
|
3870
|
+
record_ids=[],
|
|
3871
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3872
|
+
submit_type=1,
|
|
3873
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3874
|
+
)
|
|
3875
|
+
if preflight_data.get("blockers"):
|
|
3876
|
+
return self._record_write_blocked_response(
|
|
3877
|
+
raw_preflight,
|
|
3878
|
+
operation="update",
|
|
3879
|
+
normalized_payload=normalized_payload,
|
|
3880
|
+
output_profile=output_profile,
|
|
3881
|
+
human_review=True,
|
|
3882
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
3883
|
+
)
|
|
3884
|
+
route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
|
|
3885
|
+
profile=profile,
|
|
3886
|
+
app_key=app_key,
|
|
3887
|
+
record_id=record_id,
|
|
3888
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3889
|
+
preflight_data=preflight_data,
|
|
3890
|
+
verify_write=verify_write,
|
|
3891
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
3892
|
+
)
|
|
3893
|
+
if route_blocker is not None:
|
|
3894
|
+
return self._record_update_route_blocked_response(
|
|
3895
|
+
raw_preflight=raw_preflight,
|
|
3896
|
+
operation="update",
|
|
3897
|
+
normalized_payload=normalized_payload,
|
|
3898
|
+
output_profile=output_profile,
|
|
3899
|
+
human_review=True,
|
|
3900
|
+
app_key=app_key,
|
|
3901
|
+
record_id=record_id,
|
|
3902
|
+
tried_routes=tried_routes,
|
|
3903
|
+
route_blocker=route_blocker,
|
|
3904
|
+
)
|
|
3905
|
+
raw_apply = cast(JSONObject, route_apply)
|
|
3906
|
+
return self._record_write_apply_response(
|
|
3907
|
+
raw_apply,
|
|
3908
|
+
operation="update",
|
|
3909
|
+
normalized_payload=normalized_payload,
|
|
3910
|
+
output_profile=output_profile,
|
|
3911
|
+
human_review=True,
|
|
3912
|
+
preflight=raw_preflight,
|
|
3913
|
+
)
|
|
3914
|
+
|
|
3915
|
+
def _record_update_apply_with_auto_route(
|
|
3916
|
+
self,
|
|
3917
|
+
*,
|
|
3918
|
+
profile: str,
|
|
3919
|
+
app_key: str,
|
|
3920
|
+
record_id: int,
|
|
3921
|
+
normalized_answers: list[JSONObject],
|
|
3922
|
+
preflight_data: JSONObject,
|
|
3923
|
+
verify_write: bool,
|
|
3924
|
+
force_refresh_form: bool,
|
|
3925
|
+
) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
|
|
3926
|
+
"""Try record update routes in the same order a frontend user would expect."""
|
|
3927
|
+
tried_routes: list[JSONObject] = []
|
|
3928
|
+
admin_attempt = self._record_update_route_attempt(
|
|
3929
|
+
route_type="admin_direct",
|
|
3930
|
+
endpoint_kind="app_apply_update",
|
|
3931
|
+
role=1,
|
|
3932
|
+
reason="try data-manager direct edit first",
|
|
3933
|
+
)
|
|
3934
|
+
try:
|
|
3935
|
+
raw_apply = self.record_update(
|
|
3936
|
+
profile=profile,
|
|
3937
|
+
app_key=app_key,
|
|
3938
|
+
apply_id=record_id,
|
|
3939
|
+
answers=normalized_answers,
|
|
3940
|
+
fields={},
|
|
3941
|
+
role=1,
|
|
3942
|
+
verify_write=verify_write,
|
|
3943
|
+
force_refresh_form=force_refresh_form,
|
|
3944
|
+
)
|
|
3945
|
+
admin_attempt["status"] = "success"
|
|
3946
|
+
raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
|
|
3947
|
+
raw_apply["tried_routes"] = [admin_attempt]
|
|
3948
|
+
return raw_apply, [admin_attempt], None
|
|
3949
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3950
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
3951
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
3952
|
+
raise
|
|
3953
|
+
admin_attempt.update(self._record_update_route_error_payload(
|
|
3954
|
+
api_error,
|
|
3955
|
+
status="denied",
|
|
3956
|
+
error_code="ADMIN_UPDATE_PERMISSION_DENIED",
|
|
3957
|
+
))
|
|
3958
|
+
tried_routes.append(admin_attempt)
|
|
3959
|
+
|
|
3960
|
+
view_route = self._record_update_selected_custom_view_route(preflight_data)
|
|
3961
|
+
if view_route is None:
|
|
3962
|
+
tried_routes.append(
|
|
3963
|
+
self._record_update_route_attempt(
|
|
3964
|
+
route_type="view_edit",
|
|
3965
|
+
endpoint_kind="view_apply_update",
|
|
3966
|
+
status="skipped",
|
|
3967
|
+
error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
3968
|
+
reason="preflight did not select a single custom view route for this payload",
|
|
3969
|
+
)
|
|
3970
|
+
)
|
|
3971
|
+
else:
|
|
3972
|
+
view_attempt = self._record_update_route_attempt(
|
|
3973
|
+
route_type="view_edit",
|
|
3974
|
+
endpoint_kind="view_apply_update",
|
|
3975
|
+
view_id=cast(str, view_route.get("view_id")),
|
|
3976
|
+
view_key=cast(str, view_route.get("view_key")),
|
|
3977
|
+
view_name=_normalize_optional_text(view_route.get("name")),
|
|
3978
|
+
reason="fallback to frontend custom-view detail edit route",
|
|
3979
|
+
)
|
|
3980
|
+
try:
|
|
3981
|
+
raw_apply = self._record_update_via_custom_view(
|
|
3982
|
+
profile=profile,
|
|
3983
|
+
app_key=app_key,
|
|
3984
|
+
apply_id=record_id,
|
|
3985
|
+
view_key=cast(str, view_route["view_key"]),
|
|
3986
|
+
answers=normalized_answers,
|
|
3987
|
+
verify_write=verify_write,
|
|
3988
|
+
force_refresh_form=force_refresh_form,
|
|
3989
|
+
)
|
|
3990
|
+
view_attempt["status"] = "success"
|
|
3991
|
+
tried_routes.append(view_attempt)
|
|
3992
|
+
raw_apply["update_route"] = self._record_update_route_public(view_attempt)
|
|
3993
|
+
raw_apply["tried_routes"] = tried_routes
|
|
3994
|
+
return raw_apply, tried_routes, None
|
|
3995
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3996
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
3997
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
3998
|
+
raise
|
|
3999
|
+
view_attempt.update(self._record_update_route_error_payload(
|
|
4000
|
+
api_error,
|
|
4001
|
+
status="denied",
|
|
4002
|
+
error_code="VIEW_UPDATE_PERMISSION_DENIED",
|
|
4003
|
+
))
|
|
4004
|
+
tried_routes.append(view_attempt)
|
|
4005
|
+
|
|
4006
|
+
task_route = self._record_update_task_save_only_candidate(
|
|
4007
|
+
profile=profile,
|
|
4008
|
+
app_key=app_key,
|
|
4009
|
+
record_id=record_id,
|
|
4010
|
+
normalized_answers=normalized_answers,
|
|
4011
|
+
)
|
|
4012
|
+
task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
|
|
4013
|
+
if not task_route.get("available"):
|
|
4014
|
+
tried_routes.append(task_attempt or self._record_update_route_attempt(
|
|
4015
|
+
route_type="task_save_only",
|
|
4016
|
+
endpoint_kind="workflow_node_save_only",
|
|
4017
|
+
status="skipped",
|
|
4018
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4019
|
+
reason="no unique current-user todo task can edit the requested fields",
|
|
4020
|
+
))
|
|
4021
|
+
else:
|
|
4022
|
+
task_attempt = self._record_update_route_attempt(
|
|
4023
|
+
route_type="task_save_only",
|
|
4024
|
+
endpoint_kind="workflow_node_save_only",
|
|
4025
|
+
role=3,
|
|
4026
|
+
task_id=_normalize_optional_text(task_route.get("task_id")),
|
|
4027
|
+
workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
|
|
4028
|
+
reason="fallback to current-user workflow todo save-only route",
|
|
4029
|
+
)
|
|
4030
|
+
try:
|
|
4031
|
+
raw_apply = self._record_update_via_task_save_only(
|
|
4032
|
+
profile=profile,
|
|
4033
|
+
app_key=app_key,
|
|
4034
|
+
apply_id=record_id,
|
|
4035
|
+
workflow_node_id=cast(int, task_route["workflow_node_id"]),
|
|
4036
|
+
answers=normalized_answers,
|
|
4037
|
+
verify_write=verify_write,
|
|
4038
|
+
force_refresh_form=force_refresh_form,
|
|
4039
|
+
)
|
|
4040
|
+
task_attempt["status"] = "success"
|
|
4041
|
+
tried_routes.append(task_attempt)
|
|
4042
|
+
raw_apply["update_route"] = self._record_update_route_public(task_attempt)
|
|
4043
|
+
raw_apply["tried_routes"] = tried_routes
|
|
4044
|
+
return raw_apply, tried_routes, None
|
|
4045
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
4046
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
4047
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
4048
|
+
raise
|
|
4049
|
+
task_attempt.update(self._record_update_route_error_payload(
|
|
4050
|
+
api_error,
|
|
4051
|
+
status="denied",
|
|
4052
|
+
error_code="TASK_UPDATE_PERMISSION_DENIED",
|
|
4053
|
+
))
|
|
4054
|
+
tried_routes.append(task_attempt)
|
|
4055
|
+
return None, tried_routes, {
|
|
4056
|
+
"error_code": "NO_AVAILABLE_UPDATE_ROUTE",
|
|
4057
|
+
"message": "No available record update route could execute this payload for the current user.",
|
|
4058
|
+
"recommended_next_actions": [
|
|
4059
|
+
"If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
|
|
4060
|
+
"If the record is editable from a specific table view in the UI, make sure the target fields are visible and editable in that view.",
|
|
4061
|
+
"If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
|
|
4062
|
+
],
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
|
|
4066
|
+
selection = preflight_data.get("selection")
|
|
4067
|
+
if not isinstance(selection, dict):
|
|
4068
|
+
return None
|
|
4069
|
+
view = selection.get("view")
|
|
4070
|
+
if not isinstance(view, dict):
|
|
4071
|
+
return None
|
|
4072
|
+
view_id = _normalize_optional_text(view.get("view_id"))
|
|
4073
|
+
if not view_id or not view_id.startswith("custom:"):
|
|
4074
|
+
return None
|
|
4075
|
+
view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
|
|
4076
|
+
if not view_key:
|
|
4077
|
+
return None
|
|
4078
|
+
return {
|
|
4079
|
+
"view_id": view_id,
|
|
4080
|
+
"view_key": view_key,
|
|
4081
|
+
"name": view.get("name"),
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
def _record_update_route_attempt(
|
|
4085
|
+
self,
|
|
4086
|
+
*,
|
|
4087
|
+
route_type: str,
|
|
4088
|
+
endpoint_kind: str,
|
|
4089
|
+
status: str = "attempted",
|
|
4090
|
+
role: int | None = None,
|
|
4091
|
+
task_id: str | None = None,
|
|
4092
|
+
workflow_node_id: int | None = None,
|
|
4093
|
+
view_id: str | None = None,
|
|
4094
|
+
view_key: str | None = None,
|
|
4095
|
+
view_name: str | None = None,
|
|
4096
|
+
error_code: str | None = None,
|
|
4097
|
+
reason: str | None = None,
|
|
4098
|
+
) -> JSONObject:
|
|
4099
|
+
payload: JSONObject = {
|
|
4100
|
+
"route_type": route_type,
|
|
4101
|
+
"endpoint_kind": endpoint_kind,
|
|
4102
|
+
"status": status,
|
|
4103
|
+
}
|
|
4104
|
+
if role is not None:
|
|
4105
|
+
payload["role"] = role
|
|
4106
|
+
if task_id:
|
|
4107
|
+
payload["task_id"] = task_id
|
|
4108
|
+
if workflow_node_id is not None:
|
|
4109
|
+
payload["workflow_node_id"] = workflow_node_id
|
|
4110
|
+
if view_id:
|
|
4111
|
+
payload["view_id"] = view_id
|
|
4112
|
+
if view_key:
|
|
4113
|
+
payload["view_key"] = view_key
|
|
4114
|
+
if view_name:
|
|
4115
|
+
payload["view_name"] = view_name
|
|
4116
|
+
if error_code:
|
|
4117
|
+
payload["error_code"] = error_code
|
|
4118
|
+
if reason:
|
|
4119
|
+
payload["reason"] = reason
|
|
4120
|
+
return payload
|
|
4121
|
+
|
|
4122
|
+
def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
|
|
4123
|
+
return _pick_route_payload(attempt)
|
|
4124
|
+
|
|
4125
|
+
def _record_update_route_error_payload(
|
|
4126
|
+
self,
|
|
4127
|
+
exc: QingflowApiError,
|
|
4128
|
+
*,
|
|
4129
|
+
status: str,
|
|
4130
|
+
error_code: str,
|
|
4131
|
+
) -> JSONObject:
|
|
4132
|
+
payload: JSONObject = {
|
|
4133
|
+
"status": status,
|
|
4134
|
+
"error_code": error_code,
|
|
4135
|
+
"message": exc.message,
|
|
4136
|
+
}
|
|
4137
|
+
if exc.backend_code is not None:
|
|
4138
|
+
payload["backend_code"] = exc.backend_code
|
|
4139
|
+
if exc.http_status is not None:
|
|
4140
|
+
payload["http_status"] = exc.http_status
|
|
4141
|
+
if exc.request_id is not None:
|
|
4142
|
+
payload["request_id"] = exc.request_id
|
|
4143
|
+
return payload
|
|
4144
|
+
|
|
4145
|
+
def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
|
|
4146
|
+
if isinstance(exc, QingflowApiError):
|
|
4147
|
+
return exc
|
|
4148
|
+
try:
|
|
4149
|
+
payload = json.loads(str(exc))
|
|
4150
|
+
except json.JSONDecodeError:
|
|
4151
|
+
return None
|
|
4152
|
+
if not isinstance(payload, dict):
|
|
4153
|
+
return None
|
|
4154
|
+
return QingflowApiError(
|
|
4155
|
+
category=str(payload.get("category") or "backend"),
|
|
4156
|
+
message=str(payload.get("message") or exc),
|
|
4157
|
+
backend_code=payload.get("backend_code"),
|
|
4158
|
+
request_id=_normalize_optional_text(payload.get("request_id")),
|
|
4159
|
+
http_status=_coerce_count(payload.get("http_status")),
|
|
4160
|
+
details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
|
|
4161
|
+
)
|
|
4162
|
+
|
|
4163
|
+
def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
|
|
4164
|
+
if exc.backend_code in {40002, 40027, 40038, 404}:
|
|
4165
|
+
return True
|
|
4166
|
+
if exc.http_status == 404:
|
|
4167
|
+
return True
|
|
4168
|
+
return False
|
|
4169
|
+
|
|
4170
|
+
def _record_update_route_blocked_response(
|
|
4171
|
+
self,
|
|
4172
|
+
*,
|
|
4173
|
+
raw_preflight: JSONObject,
|
|
4174
|
+
operation: str,
|
|
4175
|
+
normalized_payload: JSONObject,
|
|
4176
|
+
output_profile: str,
|
|
4177
|
+
human_review: bool,
|
|
4178
|
+
app_key: str,
|
|
4179
|
+
record_id: int,
|
|
4180
|
+
tried_routes: list[JSONObject],
|
|
4181
|
+
route_blocker: JSONObject,
|
|
4182
|
+
) -> JSONObject:
|
|
4183
|
+
plan_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
4184
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
4185
|
+
warnings_payload = validation.get("warnings", [])
|
|
4186
|
+
warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
4187
|
+
warnings.append(
|
|
4188
|
+
{
|
|
4189
|
+
"code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
|
|
4190
|
+
"message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
|
|
4191
|
+
}
|
|
4192
|
+
)
|
|
4193
|
+
recommended = list(route_blocker.get("recommended_next_actions") or [])
|
|
4194
|
+
response: JSONObject = {
|
|
4195
|
+
"profile": raw_preflight.get("profile"),
|
|
4196
|
+
"ws_id": raw_preflight.get("ws_id"),
|
|
4197
|
+
"ok": False,
|
|
4198
|
+
"status": "blocked",
|
|
4199
|
+
"write_executed": False,
|
|
4200
|
+
"verification_status": "not_requested",
|
|
4201
|
+
"safe_to_retry": True,
|
|
4202
|
+
"request_route": raw_preflight.get("request_route"),
|
|
4203
|
+
"warnings": warnings,
|
|
4204
|
+
"output_profile": output_profile,
|
|
4205
|
+
"update_route": None,
|
|
4206
|
+
"tried_routes": tried_routes,
|
|
4207
|
+
"error_code": route_blocker.get("error_code"),
|
|
4208
|
+
"data": {
|
|
4209
|
+
"action": {"operation": operation, "executed": False},
|
|
4210
|
+
"resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
|
|
4211
|
+
"verification": None,
|
|
4212
|
+
"normalized_payload": normalized_payload,
|
|
4213
|
+
"blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
|
|
4214
|
+
"field_errors": [],
|
|
4215
|
+
"confirmation_requests": [],
|
|
4216
|
+
"resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
|
|
4217
|
+
"recommended_next_actions": recommended,
|
|
4218
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
4219
|
+
"error": route_blocker,
|
|
4220
|
+
"update_route": None,
|
|
4221
|
+
"tried_routes": tried_routes,
|
|
4222
|
+
},
|
|
4223
|
+
}
|
|
4224
|
+
if output_profile == "verbose":
|
|
4225
|
+
response["data"]["debug"] = {"preflight": plan_data}
|
|
4226
|
+
return response
|
|
4227
|
+
|
|
4228
|
+
def _record_update_task_save_only_candidate(
|
|
4229
|
+
self,
|
|
4230
|
+
*,
|
|
4231
|
+
profile: str,
|
|
4232
|
+
app_key: str,
|
|
4233
|
+
record_id: int,
|
|
4234
|
+
normalized_answers: list[JSONObject],
|
|
4235
|
+
) -> JSONObject:
|
|
4236
|
+
requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
|
|
4237
|
+
|
|
4238
|
+
def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
|
|
4239
|
+
attempt = self._record_update_route_attempt(
|
|
4240
|
+
route_type="task_save_only",
|
|
4241
|
+
endpoint_kind="workflow_node_save_only",
|
|
4242
|
+
status=status,
|
|
4243
|
+
error_code=error_code,
|
|
4244
|
+
reason=reason,
|
|
4245
|
+
)
|
|
4246
|
+
if extra:
|
|
4247
|
+
attempt.update(extra)
|
|
4248
|
+
return {"available": False, "attempt": attempt}
|
|
4249
|
+
|
|
4250
|
+
def runner(session_profile, context):
|
|
4251
|
+
matches: list[JSONObject] = []
|
|
4252
|
+
pages_scanned = 0
|
|
4253
|
+
for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
|
|
4254
|
+
try:
|
|
4255
|
+
task_page = self.backend.request(
|
|
4256
|
+
"POST",
|
|
4257
|
+
context,
|
|
4258
|
+
"/task/dynamic/page",
|
|
4259
|
+
json_body={
|
|
4260
|
+
"type": 1,
|
|
4261
|
+
"processStatus": 1,
|
|
4262
|
+
"appKey": app_key,
|
|
4263
|
+
"pageNum": page_num,
|
|
4264
|
+
"pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
|
|
4265
|
+
},
|
|
4266
|
+
)
|
|
4267
|
+
except QingflowApiError as exc:
|
|
4268
|
+
return unavailable(
|
|
4269
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4270
|
+
reason="current-user todo task list is unavailable",
|
|
4271
|
+
extra=self._record_update_route_error_payload(
|
|
4272
|
+
exc,
|
|
4273
|
+
status="skipped",
|
|
4274
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4275
|
+
),
|
|
4276
|
+
)
|
|
4277
|
+
pages_scanned += 1
|
|
4278
|
+
rows = task_page.get("list") if isinstance(task_page, dict) else None
|
|
4279
|
+
items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
|
|
4280
|
+
for item in items:
|
|
4281
|
+
candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
|
|
4282
|
+
if candidate_record_id == record_id:
|
|
4283
|
+
matches.append(dict(item))
|
|
4284
|
+
if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
|
|
4285
|
+
break
|
|
4286
|
+
|
|
4287
|
+
if not matches:
|
|
4288
|
+
return unavailable(
|
|
4289
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4290
|
+
reason="no current-user todo task was found for this record",
|
|
4291
|
+
extra={"pages_scanned": pages_scanned},
|
|
4292
|
+
)
|
|
4293
|
+
if len(matches) > 1:
|
|
4294
|
+
return unavailable(
|
|
4295
|
+
error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
|
|
4296
|
+
reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
|
|
4297
|
+
extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
|
|
4298
|
+
)
|
|
4299
|
+
|
|
4300
|
+
task = matches[0]
|
|
4301
|
+
workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
|
|
4302
|
+
if workflow_node_id is None:
|
|
4303
|
+
return unavailable(
|
|
4304
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4305
|
+
reason="matched todo task does not expose a workflow node id",
|
|
4306
|
+
extra={"matched_task": self._record_update_compact_task_match(task)},
|
|
4307
|
+
)
|
|
4308
|
+
try:
|
|
4309
|
+
editable_payload = self.backend.request(
|
|
4310
|
+
"GET",
|
|
4311
|
+
context,
|
|
4312
|
+
f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
|
|
4313
|
+
)
|
|
4314
|
+
except QingflowApiError as exc:
|
|
4315
|
+
return unavailable(
|
|
4316
|
+
error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
|
|
4317
|
+
reason="workflow node editable field list is unavailable; record_update will not guess task editability",
|
|
4318
|
+
extra=self._record_update_route_error_payload(
|
|
4319
|
+
exc,
|
|
4320
|
+
status="skipped",
|
|
4321
|
+
error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
|
|
4322
|
+
),
|
|
4323
|
+
)
|
|
4324
|
+
editable_question_ids = self._record_update_extract_question_ids(editable_payload)
|
|
4325
|
+
if not editable_question_ids:
|
|
4326
|
+
return unavailable(
|
|
4327
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4328
|
+
reason="workflow node editable field list is empty",
|
|
4329
|
+
extra={
|
|
4330
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4331
|
+
"workflow_node_id": workflow_node_id,
|
|
4332
|
+
},
|
|
4333
|
+
)
|
|
4334
|
+
effective_editable_question_ids = self._record_update_effective_task_editable_ids(
|
|
4335
|
+
editable_question_ids,
|
|
4336
|
+
normalized_answers=normalized_answers,
|
|
4337
|
+
)
|
|
4338
|
+
non_editable = sorted(
|
|
4339
|
+
question_id for question_id in requested_question_ids
|
|
4340
|
+
if question_id not in effective_editable_question_ids
|
|
4341
|
+
)
|
|
4342
|
+
if non_editable:
|
|
4343
|
+
return unavailable(
|
|
4344
|
+
status="denied",
|
|
4345
|
+
error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
|
|
4346
|
+
reason="one or more requested fields are not editable on the current workflow node",
|
|
4347
|
+
extra={
|
|
4348
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4349
|
+
"workflow_node_id": workflow_node_id,
|
|
4350
|
+
"non_editable_question_ids": non_editable,
|
|
4351
|
+
},
|
|
4352
|
+
)
|
|
4353
|
+
return {
|
|
4354
|
+
"available": True,
|
|
4355
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4356
|
+
"workflow_node_id": workflow_node_id,
|
|
4357
|
+
"matched_task": self._record_update_compact_task_match(task),
|
|
4358
|
+
"editable_question_ids": sorted(editable_question_ids),
|
|
4359
|
+
"effective_editable_question_ids": sorted(effective_editable_question_ids),
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
return self._run_record_tool(profile, runner)
|
|
4363
|
+
|
|
4364
|
+
def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
|
|
4365
|
+
return {
|
|
4366
|
+
key: value
|
|
4367
|
+
for key, value in {
|
|
4368
|
+
"task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
|
|
4369
|
+
"record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
|
|
4370
|
+
"workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
|
|
4371
|
+
"workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
|
|
4372
|
+
}.items()
|
|
4373
|
+
if value not in (None, "", [], {})
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
|
|
4377
|
+
question_ids: set[int] = set()
|
|
4378
|
+
for answer in answers:
|
|
4379
|
+
if not isinstance(answer, dict):
|
|
4380
|
+
continue
|
|
4381
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
4382
|
+
if que_id is not None and que_id > 0:
|
|
4383
|
+
question_ids.add(que_id)
|
|
4384
|
+
table_values = answer.get("tableValues")
|
|
4385
|
+
if not isinstance(table_values, list):
|
|
4386
|
+
continue
|
|
4387
|
+
for row in table_values:
|
|
4388
|
+
if not isinstance(row, list):
|
|
4389
|
+
continue
|
|
4390
|
+
for cell in row:
|
|
4391
|
+
if not isinstance(cell, dict):
|
|
4392
|
+
continue
|
|
4393
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
4394
|
+
if cell_que_id is not None and cell_que_id > 0:
|
|
4395
|
+
question_ids.add(cell_que_id)
|
|
4396
|
+
return question_ids
|
|
4397
|
+
|
|
4398
|
+
def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
|
|
4399
|
+
candidates: list[Any] = []
|
|
4400
|
+
if isinstance(payload, list):
|
|
4401
|
+
candidates = payload
|
|
4402
|
+
elif isinstance(payload, dict):
|
|
4403
|
+
for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
|
|
4404
|
+
value = payload.get(key)
|
|
4405
|
+
if isinstance(value, list):
|
|
4406
|
+
candidates = value
|
|
4407
|
+
break
|
|
4408
|
+
question_ids: set[int] = set()
|
|
4409
|
+
for item in candidates:
|
|
4410
|
+
value: Any = item
|
|
4411
|
+
if isinstance(item, dict):
|
|
4412
|
+
value = item.get("queId", item.get("questionId", item.get("id")))
|
|
4413
|
+
que_id = _coerce_count(value)
|
|
4414
|
+
if que_id is not None and que_id > 0:
|
|
4415
|
+
question_ids.add(que_id)
|
|
4416
|
+
return question_ids
|
|
4417
|
+
|
|
4418
|
+
def _record_update_effective_task_editable_ids(
|
|
4419
|
+
self,
|
|
4420
|
+
editable_question_ids: set[int],
|
|
4421
|
+
*,
|
|
4422
|
+
normalized_answers: list[JSONObject],
|
|
4423
|
+
) -> set[int]:
|
|
4424
|
+
effective_editable_ids = set(editable_question_ids)
|
|
4425
|
+
for answer in normalized_answers:
|
|
4426
|
+
if not isinstance(answer, dict):
|
|
4427
|
+
continue
|
|
4428
|
+
parent_que_id = _coerce_count(answer.get("queId"))
|
|
4429
|
+
if parent_que_id is None or parent_que_id <= 0:
|
|
4430
|
+
continue
|
|
4431
|
+
table_values = answer.get("tableValues")
|
|
4432
|
+
if not isinstance(table_values, list) or not table_values:
|
|
4433
|
+
continue
|
|
4434
|
+
row_subfield_ids: set[int] = set()
|
|
4435
|
+
for row in table_values:
|
|
4436
|
+
if not isinstance(row, list):
|
|
4437
|
+
continue
|
|
4438
|
+
for cell in row:
|
|
4439
|
+
if not isinstance(cell, dict):
|
|
4440
|
+
continue
|
|
4441
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
4442
|
+
if cell_que_id is not None and cell_que_id > 0:
|
|
4443
|
+
row_subfield_ids.add(cell_que_id)
|
|
4444
|
+
if row_subfield_ids & editable_question_ids:
|
|
4445
|
+
effective_editable_ids.add(parent_que_id)
|
|
4446
|
+
return effective_editable_ids
|
|
4447
|
+
|
|
4448
|
+
def _record_update_via_custom_view(
|
|
4449
|
+
self,
|
|
4450
|
+
*,
|
|
4451
|
+
profile: str,
|
|
3489
4452
|
app_key: str,
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
output_profile: str = "normal",
|
|
4453
|
+
apply_id: int,
|
|
4454
|
+
view_key: str,
|
|
4455
|
+
answers: list[JSONObject],
|
|
4456
|
+
verify_write: bool,
|
|
4457
|
+
force_refresh_form: bool,
|
|
3496
4458
|
) -> JSONObject:
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
if not
|
|
3500
|
-
raise_tool_error(QingflowApiError.config_error("
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
4459
|
+
normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
|
|
4460
|
+
normalized_view_key = view_key.strip()
|
|
4461
|
+
if not normalized_view_key:
|
|
4462
|
+
raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
|
|
4463
|
+
|
|
4464
|
+
def runner(session_profile, context):
|
|
4465
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
|
|
4466
|
+
normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
|
|
4467
|
+
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
4468
|
+
result = self.backend.request(
|
|
4469
|
+
"POST",
|
|
4470
|
+
context,
|
|
4471
|
+
f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
|
|
4472
|
+
json_body={"answers": normalized_answers},
|
|
3508
4473
|
)
|
|
3509
|
-
|
|
3510
|
-
|
|
4474
|
+
verification = self._verify_record_write_result(
|
|
4475
|
+
context,
|
|
3511
4476
|
app_key=app_key,
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
4477
|
+
apply_id=normalized_apply_id,
|
|
4478
|
+
normalized_answers=normalized_answers,
|
|
4479
|
+
index=cast(FieldIndex, index),
|
|
4480
|
+
verify_view_key=normalized_view_key,
|
|
4481
|
+
) if verify_write and index is not None else None
|
|
4482
|
+
verified = True if verification is None else bool(verification.get("verified"))
|
|
4483
|
+
return self._attach_human_review_notice(
|
|
4484
|
+
{
|
|
4485
|
+
"profile": profile,
|
|
4486
|
+
"ws_id": session_profile.selected_ws_id,
|
|
4487
|
+
"request_route": self._request_route_payload(context),
|
|
4488
|
+
"app_key": app_key,
|
|
4489
|
+
"apply_id": normalized_apply_id,
|
|
4490
|
+
"record_id": normalized_apply_id,
|
|
4491
|
+
"result": result,
|
|
4492
|
+
"normalized_answers": normalized_answers,
|
|
4493
|
+
"status": "completed" if verified else "verification_failed",
|
|
4494
|
+
"ok": True,
|
|
4495
|
+
"verify_write": verify_write,
|
|
4496
|
+
"write_verified": verified if verify_write else None,
|
|
4497
|
+
"verification": verification,
|
|
4498
|
+
"resource": _record_resource_payload(normalized_apply_id),
|
|
4499
|
+
},
|
|
4500
|
+
operation="update",
|
|
4501
|
+
target="record data",
|
|
3516
4502
|
)
|
|
3517
|
-
if dry_run:
|
|
3518
|
-
raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
|
|
3519
|
-
if record_id is None:
|
|
3520
|
-
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
3521
|
-
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
3522
|
-
if fields is not None and not isinstance(fields, dict):
|
|
3523
|
-
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3524
|
-
return self._record_update_public_single(
|
|
3525
|
-
profile=profile,
|
|
3526
|
-
app_key=app_key,
|
|
3527
|
-
record_id=record_id_int,
|
|
3528
|
-
fields=cast(JSONObject, fields or {}),
|
|
3529
|
-
verify_write=verify_write,
|
|
3530
|
-
output_profile=normalized_output_profile,
|
|
3531
|
-
)
|
|
3532
4503
|
|
|
3533
|
-
|
|
4504
|
+
return self._run_record_tool(profile, runner)
|
|
4505
|
+
|
|
4506
|
+
def _record_update_via_task_save_only(
|
|
3534
4507
|
self,
|
|
3535
4508
|
*,
|
|
3536
4509
|
profile: str,
|
|
3537
4510
|
app_key: str,
|
|
3538
|
-
|
|
3539
|
-
|
|
4511
|
+
apply_id: int,
|
|
4512
|
+
workflow_node_id: int,
|
|
4513
|
+
answers: list[JSONObject],
|
|
3540
4514
|
verify_write: bool,
|
|
3541
|
-
|
|
4515
|
+
force_refresh_form: bool,
|
|
3542
4516
|
) -> JSONObject:
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
record_ids=[],
|
|
3557
|
-
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3558
|
-
submit_type=1,
|
|
3559
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3560
|
-
)
|
|
3561
|
-
if preflight_data.get("blockers"):
|
|
3562
|
-
return self._record_write_blocked_response(
|
|
3563
|
-
raw_preflight,
|
|
3564
|
-
operation="update",
|
|
3565
|
-
normalized_payload=normalized_payload,
|
|
3566
|
-
output_profile=output_profile,
|
|
3567
|
-
human_review=True,
|
|
3568
|
-
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
4517
|
+
normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
|
|
4518
|
+
if workflow_node_id <= 0:
|
|
4519
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
|
|
4520
|
+
|
|
4521
|
+
def runner(session_profile, context):
|
|
4522
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
|
|
4523
|
+
normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
|
|
4524
|
+
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
4525
|
+
result = self.backend.request(
|
|
4526
|
+
"POST",
|
|
4527
|
+
context,
|
|
4528
|
+
f"/app/{app_key}/apply/{normalized_apply_id}",
|
|
4529
|
+
json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
|
|
3569
4530
|
)
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
profile=profile,
|
|
4531
|
+
verification = self._verify_record_write_result(
|
|
4532
|
+
context,
|
|
3573
4533
|
app_key=app_key,
|
|
3574
|
-
apply_id=
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
4534
|
+
apply_id=normalized_apply_id,
|
|
4535
|
+
normalized_answers=normalized_answers,
|
|
4536
|
+
index=cast(FieldIndex, index),
|
|
4537
|
+
) if verify_write and index is not None else None
|
|
4538
|
+
verified = True if verification is None else bool(verification.get("verified"))
|
|
4539
|
+
return self._attach_human_review_notice(
|
|
4540
|
+
{
|
|
4541
|
+
"profile": profile,
|
|
4542
|
+
"ws_id": session_profile.selected_ws_id,
|
|
4543
|
+
"request_route": self._request_route_payload(context),
|
|
4544
|
+
"app_key": app_key,
|
|
4545
|
+
"apply_id": normalized_apply_id,
|
|
4546
|
+
"record_id": normalized_apply_id,
|
|
4547
|
+
"result": result,
|
|
4548
|
+
"normalized_answers": normalized_answers,
|
|
4549
|
+
"status": "completed" if verified else "verification_failed",
|
|
4550
|
+
"ok": True,
|
|
4551
|
+
"verify_write": verify_write,
|
|
4552
|
+
"write_verified": verified if verify_write else None,
|
|
4553
|
+
"verification": verification,
|
|
4554
|
+
"resource": _record_resource_payload(normalized_apply_id),
|
|
4555
|
+
},
|
|
3584
4556
|
operation="update",
|
|
3585
|
-
|
|
3586
|
-
record_id=record_id,
|
|
3587
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
4557
|
+
target="record data",
|
|
3588
4558
|
)
|
|
3589
|
-
|
|
3590
|
-
return self.
|
|
3591
|
-
raw_apply,
|
|
3592
|
-
operation="update",
|
|
3593
|
-
normalized_payload=normalized_payload,
|
|
3594
|
-
output_profile=output_profile,
|
|
3595
|
-
human_review=True,
|
|
3596
|
-
preflight=raw_preflight,
|
|
3597
|
-
)
|
|
4559
|
+
|
|
4560
|
+
return self._run_record_tool(profile, runner)
|
|
3598
4561
|
|
|
3599
4562
|
def _record_update_public_batch(
|
|
3600
4563
|
self,
|
|
@@ -3893,6 +4856,12 @@ class RecordTools(ToolBase):
|
|
|
3893
4856
|
"confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
|
|
3894
4857
|
"resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
|
|
3895
4858
|
}
|
|
4859
|
+
update_route = response.get("update_route")
|
|
4860
|
+
if isinstance(update_route, dict):
|
|
4861
|
+
item["update_route"] = update_route
|
|
4862
|
+
tried_routes = response.get("tried_routes")
|
|
4863
|
+
if isinstance(tried_routes, list):
|
|
4864
|
+
item["tried_routes"] = tried_routes
|
|
3896
4865
|
blockers = data.get("blockers")
|
|
3897
4866
|
if isinstance(blockers, list) and blockers:
|
|
3898
4867
|
item["blockers"] = blockers
|
|
@@ -10812,6 +11781,8 @@ class RecordTools(ToolBase):
|
|
|
10812
11781
|
response_status = "verification_failed" if verification_status == "failed" else "success"
|
|
10813
11782
|
if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
|
|
10814
11783
|
response_status = raw_status or "failed"
|
|
11784
|
+
update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
|
|
11785
|
+
tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
|
|
10815
11786
|
response: JSONObject = {
|
|
10816
11787
|
"profile": raw_apply.get("profile"),
|
|
10817
11788
|
"ws_id": raw_apply.get("ws_id"),
|
|
@@ -10823,6 +11794,8 @@ class RecordTools(ToolBase):
|
|
|
10823
11794
|
"request_route": raw_apply.get("request_route"),
|
|
10824
11795
|
"warnings": warnings,
|
|
10825
11796
|
"output_profile": output_profile,
|
|
11797
|
+
"update_route": update_route,
|
|
11798
|
+
"tried_routes": tried_routes,
|
|
10826
11799
|
"data": {
|
|
10827
11800
|
"action": {"operation": operation, "executed": True},
|
|
10828
11801
|
"resource": resource,
|
|
@@ -10833,6 +11806,8 @@ class RecordTools(ToolBase):
|
|
|
10833
11806
|
"confirmation_requests": [],
|
|
10834
11807
|
"resolved_fields": resolved_fields,
|
|
10835
11808
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
11809
|
+
"update_route": update_route,
|
|
11810
|
+
"tried_routes": tried_routes,
|
|
10836
11811
|
},
|
|
10837
11812
|
}
|
|
10838
11813
|
if record_id is not None:
|
|
@@ -12404,6 +13379,7 @@ class RecordTools(ToolBase):
|
|
|
12404
13379
|
normalized_answers: list[JSONObject],
|
|
12405
13380
|
index: FieldIndex,
|
|
12406
13381
|
verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
13382
|
+
verify_view_key: str | None = None,
|
|
12407
13383
|
) -> JSONObject:
|
|
12408
13384
|
"""执行内部辅助逻辑。"""
|
|
12409
13385
|
if apply_id is None:
|
|
@@ -12415,13 +13391,36 @@ class RecordTools(ToolBase):
|
|
|
12415
13391
|
"count_mismatches": [],
|
|
12416
13392
|
}
|
|
12417
13393
|
try:
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
13394
|
+
if verify_view_key:
|
|
13395
|
+
record = self.backend.request(
|
|
13396
|
+
"GET",
|
|
13397
|
+
context,
|
|
13398
|
+
f"/view/{verify_view_key}/apply/{apply_id}",
|
|
13399
|
+
)
|
|
13400
|
+
else:
|
|
13401
|
+
record = self.backend.request(
|
|
13402
|
+
"GET",
|
|
13403
|
+
context,
|
|
13404
|
+
f"/app/{app_key}/apply/{apply_id}",
|
|
13405
|
+
params={"role": 1, "listType": verify_list_type},
|
|
13406
|
+
)
|
|
12424
13407
|
except QingflowApiError as exc:
|
|
13408
|
+
if verify_view_key:
|
|
13409
|
+
return {
|
|
13410
|
+
"verified": False,
|
|
13411
|
+
"verification_mode": "custom_view_record_detail",
|
|
13412
|
+
"field_level_verified": False,
|
|
13413
|
+
"error": "custom_view_readback_failed",
|
|
13414
|
+
"missing_fields": [],
|
|
13415
|
+
"empty_fields": [],
|
|
13416
|
+
"count_mismatches": [],
|
|
13417
|
+
"warnings": [{
|
|
13418
|
+
"code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
|
|
13419
|
+
"message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
|
|
13420
|
+
"backend_code": exc.backend_code,
|
|
13421
|
+
"http_status": exc.http_status,
|
|
13422
|
+
}],
|
|
13423
|
+
}
|
|
12425
13424
|
if exc.backend_code != 40002:
|
|
12426
13425
|
raise
|
|
12427
13426
|
return self._verify_record_write_result_via_initiated_tasks(
|
|
@@ -12485,7 +13484,7 @@ class RecordTools(ToolBase):
|
|
|
12485
13484
|
)
|
|
12486
13485
|
return {
|
|
12487
13486
|
"verified": not missing_fields and not empty_fields and not count_mismatches,
|
|
12488
|
-
"verification_mode": "initiated_record_view",
|
|
13487
|
+
"verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
|
|
12489
13488
|
"field_level_verified": True,
|
|
12490
13489
|
"missing_fields": missing_fields,
|
|
12491
13490
|
"empty_fields": empty_fields,
|
|
@@ -13397,6 +14396,13 @@ def _record_access_run_dir() -> Path:
|
|
|
13397
14396
|
return base_dir / run_id
|
|
13398
14397
|
|
|
13399
14398
|
|
|
14399
|
+
def _record_logs_run_dir() -> Path:
|
|
14400
|
+
custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
|
|
14401
|
+
base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
|
|
14402
|
+
run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
|
14403
|
+
return base_dir / run_id
|
|
14404
|
+
|
|
14405
|
+
|
|
13400
14406
|
def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
13401
14407
|
return {
|
|
13402
14408
|
"field_id": field.que_id,
|
|
@@ -14165,6 +15171,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
|
|
|
14165
15171
|
}
|
|
14166
15172
|
|
|
14167
15173
|
|
|
15174
|
+
def _record_logs_hidden_payload(source: str) -> JSONObject:
|
|
15175
|
+
return {
|
|
15176
|
+
"status": "hidden",
|
|
15177
|
+
"visible": False,
|
|
15178
|
+
"source": source,
|
|
15179
|
+
"complete": False,
|
|
15180
|
+
"items_count": 0,
|
|
15181
|
+
"pages_fetched": 0,
|
|
15182
|
+
"reported_total": None,
|
|
15183
|
+
"local_path": None,
|
|
15184
|
+
"preview_items": [],
|
|
15185
|
+
"warnings": [],
|
|
15186
|
+
}
|
|
15187
|
+
|
|
15188
|
+
|
|
15189
|
+
def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
|
|
15190
|
+
return {
|
|
15191
|
+
"status": "unavailable",
|
|
15192
|
+
"visible": None,
|
|
15193
|
+
"source": source,
|
|
15194
|
+
"reason": reason,
|
|
15195
|
+
"complete": False,
|
|
15196
|
+
"items_count": 0,
|
|
15197
|
+
"pages_fetched": 0,
|
|
15198
|
+
"reported_total": None,
|
|
15199
|
+
"local_path": None,
|
|
15200
|
+
"preview_items": [],
|
|
15201
|
+
"warnings": [],
|
|
15202
|
+
}
|
|
15203
|
+
|
|
15204
|
+
|
|
15205
|
+
def _record_logs_fetch_all_to_jsonl(
|
|
15206
|
+
*,
|
|
15207
|
+
fetch_page,
|
|
15208
|
+
normalizer,
|
|
15209
|
+
source: str,
|
|
15210
|
+
file_path: Path,
|
|
15211
|
+
deadline: float,
|
|
15212
|
+
) -> JSONObject: # type: ignore[no-untyped-def]
|
|
15213
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
15214
|
+
page_num = 1
|
|
15215
|
+
pages_fetched = 0
|
|
15216
|
+
items_count = 0
|
|
15217
|
+
reported_total: int | None = None
|
|
15218
|
+
preview_items: list[JSONObject] = []
|
|
15219
|
+
warnings: list[JSONObject] = []
|
|
15220
|
+
stopped_reason: str | None = None
|
|
15221
|
+
complete = True
|
|
15222
|
+
|
|
15223
|
+
with file_path.open("w", encoding="utf-8") as handle:
|
|
15224
|
+
while True:
|
|
15225
|
+
if _record_logs_time_budget_exceeded(deadline=deadline):
|
|
15226
|
+
complete = False
|
|
15227
|
+
stopped_reason = "time_budget_exceeded"
|
|
15228
|
+
warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
|
|
15229
|
+
break
|
|
15230
|
+
payload = fetch_page(page_num)
|
|
15231
|
+
pages_fetched += 1
|
|
15232
|
+
items = _record_detail_page_items(payload)
|
|
15233
|
+
if reported_total is None:
|
|
15234
|
+
reported_total = _record_detail_page_total(payload)
|
|
15235
|
+
if not items:
|
|
15236
|
+
break
|
|
15237
|
+
for item in items:
|
|
15238
|
+
normalized = normalizer(item)
|
|
15239
|
+
handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
|
|
15240
|
+
items_count += 1
|
|
15241
|
+
if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
|
|
15242
|
+
preview_items.append(normalized)
|
|
15243
|
+
if items_count >= RECORD_LOGS_MAX_ITEMS:
|
|
15244
|
+
complete = False
|
|
15245
|
+
stopped_reason = "item_limit_exceeded"
|
|
15246
|
+
warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
|
|
15247
|
+
break
|
|
15248
|
+
if stopped_reason:
|
|
15249
|
+
break
|
|
15250
|
+
if reported_total is not None and items_count >= reported_total:
|
|
15251
|
+
break
|
|
15252
|
+
if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
|
|
15253
|
+
break
|
|
15254
|
+
page_num += 1
|
|
15255
|
+
|
|
15256
|
+
return {
|
|
15257
|
+
"status": "ok" if complete else "partial",
|
|
15258
|
+
"visible": True,
|
|
15259
|
+
"source": source,
|
|
15260
|
+
"complete": complete,
|
|
15261
|
+
"items_count": items_count,
|
|
15262
|
+
"pages_fetched": pages_fetched,
|
|
15263
|
+
"page_size": RECORD_LOGS_PAGE_SIZE,
|
|
15264
|
+
"reported_total": reported_total,
|
|
15265
|
+
"local_path": str(file_path),
|
|
15266
|
+
"preview_items": preview_items,
|
|
15267
|
+
"warnings": warnings,
|
|
15268
|
+
"stopped_reason": stopped_reason,
|
|
15269
|
+
}
|
|
15270
|
+
|
|
15271
|
+
|
|
15272
|
+
def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
|
|
15273
|
+
return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
|
|
15274
|
+
|
|
15275
|
+
|
|
15276
|
+
def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
|
|
15277
|
+
return {
|
|
15278
|
+
"code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
|
|
15279
|
+
"source": source,
|
|
15280
|
+
"message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
|
|
15281
|
+
"pages_fetched": pages_fetched,
|
|
15282
|
+
"items_count": items_count,
|
|
15283
|
+
}
|
|
15284
|
+
|
|
15285
|
+
|
|
15286
|
+
def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
|
|
15287
|
+
return {
|
|
15288
|
+
"code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
|
|
15289
|
+
"source": source,
|
|
15290
|
+
"message": f"record_logs_get stopped after the internal {item_limit} item limit.",
|
|
15291
|
+
"item_limit": item_limit,
|
|
15292
|
+
}
|
|
15293
|
+
|
|
15294
|
+
|
|
15295
|
+
def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
|
|
15296
|
+
statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
|
|
15297
|
+
if statuses == {"unavailable"}:
|
|
15298
|
+
return "unavailable"
|
|
15299
|
+
if "partial" in statuses or "unavailable" in statuses:
|
|
15300
|
+
return "partial"
|
|
15301
|
+
return "success"
|
|
15302
|
+
|
|
15303
|
+
|
|
15304
|
+
def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
|
|
15305
|
+
data_integrity = _record_logs_section_integrity(data_logs)
|
|
15306
|
+
workflow_integrity = _record_logs_section_integrity(workflow_logs)
|
|
15307
|
+
return {
|
|
15308
|
+
"data_logs": data_integrity,
|
|
15309
|
+
"workflow_logs": workflow_integrity,
|
|
15310
|
+
"safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
|
|
15311
|
+
}
|
|
15312
|
+
|
|
15313
|
+
|
|
15314
|
+
def _record_logs_section_integrity(section: JSONObject) -> str:
|
|
15315
|
+
status = str(section.get("status") or "")
|
|
15316
|
+
if status == "ok" and section.get("complete") is True:
|
|
15317
|
+
return "full"
|
|
15318
|
+
if status == "hidden":
|
|
15319
|
+
return "hidden"
|
|
15320
|
+
if status == "partial":
|
|
15321
|
+
return "partial"
|
|
15322
|
+
if status == "unavailable":
|
|
15323
|
+
return "unavailable"
|
|
15324
|
+
return "unknown"
|
|
15325
|
+
|
|
15326
|
+
|
|
14168
15327
|
def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
|
|
14169
15328
|
items = _record_detail_page_items(payload)
|
|
14170
15329
|
total = _record_detail_page_total(payload)
|