@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50
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 +4 -4
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +91 -410
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
- package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
- package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/data/README.md +32 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
- package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
- package/skills/qingflow-common/SKILL.md +10 -17
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +142 -12
- package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
- package/src/qingflow_mcp/builder_facade/service.py +749 -6
- package/src/qingflow_mcp/builder_facade/workflow_spec.py +155 -3
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/auth.py +1 -1
- package/src/qingflow_mcp/cli/commands/builder.py +57 -41
- package/src/qingflow_mcp/cli/commands/data.py +134 -0
- package/src/qingflow_mcp/cli/commands/record.py +1 -1
- package/src/qingflow_mcp/cli/context.py +4 -1
- package/src/qingflow_mcp/cli/formatters.py +159 -4
- package/src/qingflow_mcp/cli/main.py +4 -1
- package/src/qingflow_mcp/config.py +60 -0
- package/src/qingflow_mcp/public_surface.py +9 -0
- package/src/qingflow_mcp/resource_uri.py +241 -0
- package/src/qingflow_mcp/response_trim.py +53 -5
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +57 -2
- package/src/qingflow_mcp/server_app_user.py +10 -3
- package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +284 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
- package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
|
@@ -8,7 +8,10 @@ from typing import Any, TextIO
|
|
|
8
8
|
def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
|
|
9
9
|
text = _format_cancelled_result(result)
|
|
10
10
|
if text is None:
|
|
11
|
-
if
|
|
11
|
+
if hint in _DATA_FORMAT_HINTS:
|
|
12
|
+
# 数据面失败也必须保留 field_errors 与列表型 hints,不能先走通用失败裁剪。
|
|
13
|
+
text = _format_data_result(result)
|
|
14
|
+
elif _is_failed_result(result) and hint != "task_action_execute":
|
|
12
15
|
text = _format_failed_result(result)
|
|
13
16
|
else:
|
|
14
17
|
formatter = _FORMATTERS.get(hint, _format_generic)
|
|
@@ -90,6 +93,92 @@ def _format_generic(result: dict[str, Any]) -> str:
|
|
|
90
93
|
return "\n".join(lines) + "\n"
|
|
91
94
|
|
|
92
95
|
|
|
96
|
+
def _format_data_result(result: dict[str, Any]) -> str:
|
|
97
|
+
lines = [f"Status: {result.get('status') or ('success' if result.get('ok') is not False else 'failed')}"]
|
|
98
|
+
if result.get("format") == "csv":
|
|
99
|
+
files = result.get("files") if isinstance(result.get("files"), list) else []
|
|
100
|
+
lines.extend(
|
|
101
|
+
[
|
|
102
|
+
f"Local dir: {result.get('local_dir') or '-'}",
|
|
103
|
+
f"Files: {len(files)}",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
for item in files:
|
|
107
|
+
if isinstance(item, dict):
|
|
108
|
+
lines.append(
|
|
109
|
+
f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)"
|
|
110
|
+
)
|
|
111
|
+
lines.extend(
|
|
112
|
+
[
|
|
113
|
+
f"Rows: {result.get('row_count')}",
|
|
114
|
+
f"Complete: {result.get('complete')}",
|
|
115
|
+
f"Truncated: {result.get('truncated')}",
|
|
116
|
+
f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
|
|
117
|
+
f"Stopped reason: {result.get('stopped_reason') or '-'}",
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
access = result.get("access") if isinstance(result.get("access"), dict) else {}
|
|
121
|
+
if access:
|
|
122
|
+
sources = access.get("scope_sources") if isinstance(access.get("scope_sources"), list) else []
|
|
123
|
+
lines.append(
|
|
124
|
+
f"Access: level={access.get('level') or '-'} / can_write={access.get('can_write')}"
|
|
125
|
+
f" / scope_sources={','.join(str(item) for item in sources) or '-'}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
records = result.get("records") if isinstance(result.get("records"), dict) else {}
|
|
129
|
+
if records:
|
|
130
|
+
items = records.get("items") if isinstance(records.get("items"), list) else []
|
|
131
|
+
lines.append(
|
|
132
|
+
"Pagination: "
|
|
133
|
+
f"page={records.get('page')} / requested_page_size={records.get('requested_page_size')} / "
|
|
134
|
+
f"page_size={records.get('page_size')} / total={records.get('total')} / "
|
|
135
|
+
f"has_more={records.get('has_more')} / returned={len(items)}"
|
|
136
|
+
)
|
|
137
|
+
record = result.get("record") if isinstance(result.get("record"), dict) else {}
|
|
138
|
+
if record:
|
|
139
|
+
lines.append(f"Record ID: {record.get('record_id') or '-'}")
|
|
140
|
+
items = result.get("items") if isinstance(result.get("items"), list) else []
|
|
141
|
+
if items:
|
|
142
|
+
lines.append(f"Items: {len(items)}")
|
|
143
|
+
|
|
144
|
+
for key, label in (
|
|
145
|
+
("error_code", "Error Code"),
|
|
146
|
+
("message", "Message"),
|
|
147
|
+
("write_executed", "Write Executed"),
|
|
148
|
+
("retry_safe", "Retry Safe"),
|
|
149
|
+
):
|
|
150
|
+
if key in result:
|
|
151
|
+
lines.append(f"{label}: {result.get(key)}")
|
|
152
|
+
|
|
153
|
+
field_errors = result.get("field_errors") if isinstance(result.get("field_errors"), list) else []
|
|
154
|
+
if field_errors:
|
|
155
|
+
lines.append("Field Errors:")
|
|
156
|
+
_append_data_field_errors(lines, field_errors)
|
|
157
|
+
for index, item in enumerate(items):
|
|
158
|
+
if not isinstance(item, dict) or not isinstance(item.get("field_errors"), list):
|
|
159
|
+
continue
|
|
160
|
+
lines.append(f"Item {index} Field Errors:")
|
|
161
|
+
_append_data_field_errors(lines, item["field_errors"])
|
|
162
|
+
|
|
163
|
+
hints = result.get("hints") if isinstance(result.get("hints"), list) else []
|
|
164
|
+
lines.append(f"Hints: {len(hints)}")
|
|
165
|
+
for hint in hints:
|
|
166
|
+
if isinstance(hint, dict):
|
|
167
|
+
lines.append(f"- {hint.get('code') or '-'}: {hint.get('message') or '-'}")
|
|
168
|
+
else:
|
|
169
|
+
lines.append(f"- {hint}")
|
|
170
|
+
return "\n".join(lines) + "\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _append_data_field_errors(lines: list[str], field_errors: list[Any]) -> None:
|
|
174
|
+
for error in field_errors:
|
|
175
|
+
if isinstance(error, dict):
|
|
176
|
+
lines.append(
|
|
177
|
+
f"- {error.get('path') or '-'}: {error.get('message') or '-'}"
|
|
178
|
+
f" / fix_hint={error.get('fix_hint') or '-'}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
93
182
|
def _format_whoami(result: dict[str, Any]) -> str:
|
|
94
183
|
lines = [
|
|
95
184
|
f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
|
|
@@ -195,9 +284,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
|
|
|
195
284
|
str(item.get("app_key") or item.get("appKey") or ""),
|
|
196
285
|
str(item.get("app_name") or item.get("name") or item.get("title") or ""),
|
|
197
286
|
str(item.get("package_name") or item.get("tag_name") or ""),
|
|
287
|
+
str(item.get("uri") or ""),
|
|
198
288
|
]
|
|
199
289
|
)
|
|
200
|
-
return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
|
|
290
|
+
return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
|
|
201
291
|
|
|
202
292
|
|
|
203
293
|
def _format_app_get(result: dict[str, Any]) -> str:
|
|
@@ -205,6 +295,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
205
295
|
lines = [
|
|
206
296
|
f"App: {data.get('app_name') or '-'}",
|
|
207
297
|
f"App Key: {data.get('app_key') or '-'}",
|
|
298
|
+
f"URI: {data.get('uri') or '-'}",
|
|
208
299
|
f"Can Create: {data.get('can_create')}",
|
|
209
300
|
]
|
|
210
301
|
import_capability = data.get("import_capability")
|
|
@@ -228,7 +319,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
228
319
|
lines.append(f"Accessible Views: {len(views)}")
|
|
229
320
|
for item in views[:10]:
|
|
230
321
|
if isinstance(item, dict):
|
|
231
|
-
lines.append(f"- {item.get('view_id')}: {item.get('name')}")
|
|
322
|
+
lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
|
|
232
323
|
_append_warnings(lines, result.get("warnings"))
|
|
233
324
|
return "\n".join(lines) + "\n"
|
|
234
325
|
|
|
@@ -300,6 +391,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
|
|
|
300
391
|
f"View Key: {data.get('view_key') or '-'}",
|
|
301
392
|
f"App Key: {data.get('app_key') or '-'}",
|
|
302
393
|
f"View Type: {data.get('view_type') or '-'}",
|
|
394
|
+
f"URI: {data.get('uri') or '-'}",
|
|
303
395
|
f"Analysis Supported: {data.get('analysis_supported')}",
|
|
304
396
|
]
|
|
305
397
|
if export_capability:
|
|
@@ -1184,6 +1276,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1184
1276
|
("view_key", "View Key"),
|
|
1185
1277
|
("chart_id", "Chart ID"),
|
|
1186
1278
|
("package_id", "Package ID"),
|
|
1279
|
+
("uri", "URI"),
|
|
1187
1280
|
("verified", "Verified"),
|
|
1188
1281
|
("write_executed", "Write Executed"),
|
|
1189
1282
|
("delete_executed", "Delete Executed"),
|
|
@@ -1216,6 +1309,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1216
1309
|
"associations",
|
|
1217
1310
|
"package_ids",
|
|
1218
1311
|
"app_keys",
|
|
1312
|
+
"resources",
|
|
1219
1313
|
):
|
|
1220
1314
|
value = result.get(key)
|
|
1221
1315
|
if not isinstance(value, list) and isinstance(data, dict):
|
|
@@ -1227,6 +1321,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1227
1321
|
lines.append("- " + _builder_item_summary(item))
|
|
1228
1322
|
if len(value) > len(visible_items):
|
|
1229
1323
|
lines.append(f"... {len(value) - len(visible_items)} more")
|
|
1324
|
+
if key == "apps":
|
|
1325
|
+
uri_lines = [
|
|
1326
|
+
line
|
|
1327
|
+
for item in value
|
|
1328
|
+
for line in _builder_batch_item_uri_lines(item)
|
|
1329
|
+
]
|
|
1330
|
+
if uri_lines:
|
|
1331
|
+
lines.append("Resource URIs:")
|
|
1332
|
+
lines.extend(f"- {line}" for line in uri_lines)
|
|
1230
1333
|
components = result.get("components") if isinstance(result.get("components"), list) else []
|
|
1231
1334
|
if components:
|
|
1232
1335
|
lines.append("Component Refs:")
|
|
@@ -1295,6 +1398,7 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1295
1398
|
"uid",
|
|
1296
1399
|
"userId",
|
|
1297
1400
|
"email",
|
|
1401
|
+
"uri",
|
|
1298
1402
|
)
|
|
1299
1403
|
parts = [
|
|
1300
1404
|
f"{key}={item.get(key)}"
|
|
@@ -1306,6 +1410,50 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1306
1410
|
return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
|
|
1307
1411
|
|
|
1308
1412
|
|
|
1413
|
+
def _builder_batch_item_uri_lines(item: Any) -> list[str]:
|
|
1414
|
+
if not isinstance(item, dict):
|
|
1415
|
+
return []
|
|
1416
|
+
item_status = str(item.get("status") or "").lower()
|
|
1417
|
+
if item_status not in {"success", "partial_success"}:
|
|
1418
|
+
return []
|
|
1419
|
+
data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
|
|
1420
|
+
if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
|
|
1421
|
+
return []
|
|
1422
|
+
lines: list[str] = []
|
|
1423
|
+
if data.get("uri"):
|
|
1424
|
+
lines.append(f"URI: {data['uri']}")
|
|
1425
|
+
views = data.get("views") if isinstance(data.get("views"), list) else []
|
|
1426
|
+
for view in views:
|
|
1427
|
+
if (
|
|
1428
|
+
isinstance(view, dict)
|
|
1429
|
+
and view.get("uri")
|
|
1430
|
+
and _builder_uri_is_confirmed(view, fallback_status=item_status)
|
|
1431
|
+
):
|
|
1432
|
+
lines.append(f"View URI: {view['uri']}")
|
|
1433
|
+
return lines
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
|
|
1437
|
+
status = str(value.get("status") or fallback_status).lower()
|
|
1438
|
+
operation = str(value.get("operation") or "").lower()
|
|
1439
|
+
return (
|
|
1440
|
+
status in {"success", "partial_success", "unchanged", "verified"}
|
|
1441
|
+
and operation
|
|
1442
|
+
not in {
|
|
1443
|
+
"failed",
|
|
1444
|
+
"pending",
|
|
1445
|
+
"planned",
|
|
1446
|
+
"readback_pending",
|
|
1447
|
+
"pending_readback",
|
|
1448
|
+
"unknown",
|
|
1449
|
+
"remove",
|
|
1450
|
+
"removed",
|
|
1451
|
+
"delete",
|
|
1452
|
+
"deleted",
|
|
1453
|
+
}
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
|
|
1309
1457
|
def _format_file_upload_local(result: dict[str, Any]) -> str:
|
|
1310
1458
|
lines = [
|
|
1311
1459
|
f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
|
|
@@ -1568,7 +1716,7 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1568
1716
|
|
|
1569
1717
|
|
|
1570
1718
|
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1571
|
-
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n
|
|
1719
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
|
|
1572
1720
|
|
|
1573
1721
|
|
|
1574
1722
|
_FORMATTERS = {
|
|
@@ -1610,4 +1758,11 @@ _FORMATTERS = {
|
|
|
1610
1758
|
"builder_summary": _format_builder_summary,
|
|
1611
1759
|
"file_upload_local": _format_file_upload_local,
|
|
1612
1760
|
"feedback_submit": _format_feedback_submit,
|
|
1761
|
+
"data_query": _format_data_result,
|
|
1762
|
+
"data_get": _format_data_result,
|
|
1763
|
+
"data_insert": _format_data_result,
|
|
1764
|
+
"data_update": _format_data_result,
|
|
1765
|
+
"data_delete": _format_data_result,
|
|
1613
1766
|
}
|
|
1767
|
+
|
|
1768
|
+
_DATA_FORMAT_HINTS = {"data_query", "data_get", "data_insert", "data_update", "data_delete"}
|
|
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
6
7
|
from typing import Any, Callable, TextIO
|
|
7
8
|
|
|
8
9
|
from ..errors import QingflowApiError, backend_code_value_int, message_looks_like_invalid_token
|
|
9
10
|
from ..public_surface import cli_public_tool_spec_from_namespace
|
|
11
|
+
from ..resource_uri import cli_resource_base_url
|
|
10
12
|
from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
|
|
11
13
|
from ..tools.ai_builder_tools import _attach_builder_apply_envelope
|
|
12
14
|
from ..version import get_cli_version, get_cli_version_info
|
|
@@ -94,7 +96,8 @@ def run(
|
|
|
94
96
|
context = context_factory()
|
|
95
97
|
if not bool(args.json):
|
|
96
98
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
97
|
-
|
|
99
|
+
with cli_resource_base_url(os.getenv("QINGFLOW_BASE_URL") or ""):
|
|
100
|
+
result = handler(args, context)
|
|
98
101
|
except SystemExit as exc:
|
|
99
102
|
return int(exc.code or 0)
|
|
100
103
|
except RuntimeError as exc:
|
|
@@ -285,6 +285,66 @@ def get_credit_meter_enabled() -> bool:
|
|
|
285
285
|
return normalized in {"1", "true", "yes", "on"}
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
# BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
|
|
289
|
+
FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT = 5
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_flow_edit_maintenance_threshold() -> int | None:
|
|
293
|
+
"""读取流程编辑维护闸门的当前节点数阈值。
|
|
294
|
+
|
|
295
|
+
配置键 flow_edit.maintenance_node_threshold / 环境变量
|
|
296
|
+
QINGFLOW_MCP_FLOW_EDIT_MAINTENANCE_THRESHOLD,默认 5;
|
|
297
|
+
取值 off|disabled|none(大小写不敏感)返回 None 表示闸门关闭
|
|
298
|
+
(评测批量等场景的逃生门);解析失败或负数回退默认值 5。
|
|
299
|
+
"""
|
|
300
|
+
value = get_config_value(
|
|
301
|
+
"flow_edit.maintenance_node_threshold",
|
|
302
|
+
env_var="QINGFLOW_MCP_FLOW_EDIT_MAINTENANCE_THRESHOLD",
|
|
303
|
+
default=FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT,
|
|
304
|
+
)
|
|
305
|
+
normalized = str(value if value is not None else "").strip().lower()
|
|
306
|
+
if normalized in {"off", "disabled", "none"}:
|
|
307
|
+
return None
|
|
308
|
+
try:
|
|
309
|
+
threshold = int(normalized)
|
|
310
|
+
except ValueError:
|
|
311
|
+
return FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT
|
|
312
|
+
return threshold if threshold >= 0 else FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT
|
|
313
|
+
# END temporary flow edit maintenance guard.
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
DEFAULT_HTTP_RETRY_ATTEMPTS = 3
|
|
317
|
+
DEFAULT_HTTP_RETRY_BASE_SECONDS = 3.0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_http_retry_attempts() -> int:
|
|
321
|
+
"""传输层单请求最大尝试次数(含首次),≤1 视为不重试。"""
|
|
322
|
+
value = get_config_value(
|
|
323
|
+
"http.retry_attempts",
|
|
324
|
+
env_var="QINGFLOW_MCP_HTTP_RETRY_ATTEMPTS",
|
|
325
|
+
default=DEFAULT_HTTP_RETRY_ATTEMPTS,
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
parsed = int(value)
|
|
329
|
+
except (ValueError, TypeError):
|
|
330
|
+
return DEFAULT_HTTP_RETRY_ATTEMPTS
|
|
331
|
+
return max(parsed, 1)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def get_http_retry_base_seconds() -> float:
|
|
335
|
+
"""传输层重试线性退避基数:第 n 次失败后等待 base × n 秒。"""
|
|
336
|
+
value = get_config_value(
|
|
337
|
+
"http.retry_base_seconds",
|
|
338
|
+
env_var="QINGFLOW_MCP_HTTP_RETRY_BASE_SECONDS",
|
|
339
|
+
default=DEFAULT_HTTP_RETRY_BASE_SECONDS,
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
parsed = float(value)
|
|
343
|
+
except (ValueError, TypeError):
|
|
344
|
+
return DEFAULT_HTTP_RETRY_BASE_SECONDS
|
|
345
|
+
return parsed if parsed >= 0 else DEFAULT_HTTP_RETRY_BASE_SECONDS
|
|
346
|
+
|
|
347
|
+
|
|
288
348
|
def get_credit_usage_base_url() -> str | None:
|
|
289
349
|
value = get_config_value(
|
|
290
350
|
"credit_meter.apaas.base_url",
|
|
@@ -40,6 +40,11 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
40
40
|
PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
|
|
41
41
|
PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
|
|
42
42
|
PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
|
|
43
|
+
PublicToolSpec(USER_DOMAIN, "data_query", ("data_query",), ("data", "query"), cli_show_effective_context=True),
|
|
44
|
+
PublicToolSpec(USER_DOMAIN, "data_get", ("data_get",), ("data", "get"), cli_show_effective_context=True),
|
|
45
|
+
PublicToolSpec(USER_DOMAIN, "data_insert", ("data_insert",), ("data", "insert"), cli_show_effective_context=True, cli_context_write=True),
|
|
46
|
+
PublicToolSpec(USER_DOMAIN, "data_update", ("data_update",), ("data", "update"), cli_show_effective_context=True, cli_context_write=True),
|
|
47
|
+
PublicToolSpec(USER_DOMAIN, "data_delete", ("data_delete",), ("data", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
43
48
|
PublicToolSpec(USER_DOMAIN, "portal_list", ("portal_list",), ("portal", "list"), cli_show_effective_context=True),
|
|
44
49
|
PublicToolSpec(USER_DOMAIN, "portal_get", ("portal_get",), ("portal", "get"), cli_show_effective_context=True),
|
|
45
50
|
PublicToolSpec(USER_DOMAIN, "view_get", ("view_get",), ("view", "get"), cli_show_effective_context=True),
|
|
@@ -128,6 +133,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
128
133
|
PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
|
|
129
134
|
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
|
|
130
135
|
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
|
|
136
|
+
PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
|
|
131
137
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
|
|
132
138
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
|
|
133
139
|
PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -168,6 +174,9 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
168
174
|
PublicToolSpec(BUILDER_DOMAIN, "chart_get", ("chart_get",), ("builder", "chart", "get"), has_contract=True, cli_show_effective_context=True),
|
|
169
175
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_get_schema", ("app_flow_get_schema",), ("builder", "flow", "schema"), has_contract=True, cli_show_effective_context=True),
|
|
170
176
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_get", ("app_flow_get",), ("builder", "flow", "get"), has_contract=True, cli_show_effective_context=True),
|
|
177
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_flow_validate", ("app_flow_validate",), ("builder", "flow", "validate"), has_contract=True, cli_show_effective_context=True),
|
|
178
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_flow_list_versions", ("app_flow_list_versions",), ("builder", "flow", "versions"), has_contract=True, cli_show_effective_context=True),
|
|
179
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_flow_restore_version", ("app_flow_restore_version",), ("builder", "flow", "restore"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
171
180
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_apply", ("app_flow_apply",), ("builder", "flow", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
172
181
|
PublicToolSpec(BUILDER_DOMAIN, "app_views_apply", ("app_views_apply",), ("builder", "views", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
173
182
|
PublicToolSpec(BUILDER_DOMAIN, "app_charts_apply", ("app_charts_apply",), ("builder", "charts", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
|
|
12
|
+
from .errors import QingflowApiError
|
|
13
|
+
from .json_types import JSONObject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
|
|
17
|
+
_ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
18
|
+
_CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
|
|
19
|
+
"qingflow_cli_resource_base_url",
|
|
20
|
+
default=None,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ResourcePath:
|
|
26
|
+
resource_type: str
|
|
27
|
+
ws_id: int
|
|
28
|
+
package_id: int | None = None
|
|
29
|
+
app_key: str | None = None
|
|
30
|
+
view_kind: str | None = None
|
|
31
|
+
view_key: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def cli_resource_base_url(value: str) -> Iterator[None]:
|
|
36
|
+
"""让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
|
|
37
|
+
token = _CLI_RESOURCE_BASE_URL.set(value)
|
|
38
|
+
try:
|
|
39
|
+
yield
|
|
40
|
+
finally:
|
|
41
|
+
_CLI_RESOURCE_BASE_URL.reset(token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_resource_identity(value: object, field: str = "resource_id") -> str:
|
|
45
|
+
"""按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
|
|
46
|
+
return _validate_identity(value, field)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
|
|
50
|
+
"""按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
|
|
51
|
+
return int(_positive_int_segment(value, field))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_resource_uri(
|
|
55
|
+
*,
|
|
56
|
+
ws_id: object,
|
|
57
|
+
resource_type: str,
|
|
58
|
+
package_id: object = None,
|
|
59
|
+
app_key: object = None,
|
|
60
|
+
view_kind: str | None = None,
|
|
61
|
+
view_key: object = None,
|
|
62
|
+
web_origin: str | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""按固定公共合同构造资源 URI,不接受通用目标路由。"""
|
|
65
|
+
workspace = _positive_int_segment(ws_id, "ws_id")
|
|
66
|
+
if resource_type == "package":
|
|
67
|
+
path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
|
|
68
|
+
elif resource_type == "app":
|
|
69
|
+
path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
|
|
70
|
+
elif resource_type == "view":
|
|
71
|
+
app_segment = _identity_segment(app_key, "app_key")
|
|
72
|
+
if view_kind == "custom":
|
|
73
|
+
view_segment = _identity_segment(view_key, "view_key")
|
|
74
|
+
elif view_kind == "system":
|
|
75
|
+
name = str(view_key or "").strip()
|
|
76
|
+
if name not in SYSTEM_VIEW_NAMES:
|
|
77
|
+
raise ValueError("system view name is not supported")
|
|
78
|
+
view_segment = name
|
|
79
|
+
else:
|
|
80
|
+
raise ValueError("view_kind must be custom or system")
|
|
81
|
+
path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("resource_type is not supported")
|
|
84
|
+
return f"{resolve_resource_web_origin(web_origin)}{path}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_resource_path(value: str) -> ResourcePath:
|
|
88
|
+
"""严格解析资源 pathname,供免登录签发前校验工作区。"""
|
|
89
|
+
try:
|
|
90
|
+
parsed = urlsplit(value)
|
|
91
|
+
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
|
|
92
|
+
raise ValueError
|
|
93
|
+
parts = parsed.path.split("/")
|
|
94
|
+
if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
|
|
95
|
+
raise ValueError
|
|
96
|
+
ws_id = int(_decode_segment(parts[3], "ws_id"))
|
|
97
|
+
if ws_id <= 0:
|
|
98
|
+
raise ValueError
|
|
99
|
+
if len(parts) == 6 and parts[4] == "package":
|
|
100
|
+
package_id = int(_decode_segment(parts[5], "package_id"))
|
|
101
|
+
if package_id <= 0:
|
|
102
|
+
raise ValueError
|
|
103
|
+
return ResourcePath("package", ws_id, package_id=package_id)
|
|
104
|
+
if len(parts) == 6 and parts[4] == "app":
|
|
105
|
+
return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
|
|
106
|
+
if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
|
|
107
|
+
app_key = _decode_segment(parts[5], "app_key")
|
|
108
|
+
view_kind = parts[7]
|
|
109
|
+
view_key = _decode_segment(parts[8], "view_key")
|
|
110
|
+
if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
|
|
111
|
+
raise ValueError
|
|
112
|
+
if view_kind not in {"custom", "system"}:
|
|
113
|
+
raise ValueError
|
|
114
|
+
return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
|
|
115
|
+
except (TypeError, ValueError, UnicodeError):
|
|
116
|
+
pass
|
|
117
|
+
raise ValueError("resource path is invalid")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
|
|
121
|
+
try:
|
|
122
|
+
return build_resource_uri(**kwargs), None
|
|
123
|
+
except (QingflowApiError, ValueError, UnicodeError) as error:
|
|
124
|
+
return None, {
|
|
125
|
+
"code": "RESOURCE_URI_UNAVAILABLE",
|
|
126
|
+
"message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _positive_int_segment(value: object, field: str) -> str:
|
|
131
|
+
if isinstance(value, bool):
|
|
132
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
133
|
+
try:
|
|
134
|
+
parsed = int(value)
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
raise ValueError(f"{field} must be a positive integer") from None
|
|
137
|
+
if parsed <= 0 or str(value).strip() != str(parsed):
|
|
138
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
139
|
+
return str(parsed)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _normalize_web_origin(value: str) -> str:
|
|
143
|
+
try:
|
|
144
|
+
parsed = urlsplit(value.strip())
|
|
145
|
+
if (
|
|
146
|
+
parsed.scheme not in {"http", "https"}
|
|
147
|
+
or not parsed.hostname
|
|
148
|
+
or parsed.username is not None
|
|
149
|
+
or parsed.password is not None
|
|
150
|
+
or parsed.path not in {"", "/"}
|
|
151
|
+
or parsed.query
|
|
152
|
+
or parsed.fragment
|
|
153
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
154
|
+
):
|
|
155
|
+
raise ValueError
|
|
156
|
+
_ = parsed.port
|
|
157
|
+
except (AttributeError, ValueError):
|
|
158
|
+
raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
|
|
159
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_resource_web_origin(web_origin: str | None = None) -> str:
|
|
163
|
+
"""解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
|
|
164
|
+
if web_origin is not None:
|
|
165
|
+
return _normalize_web_origin(web_origin)
|
|
166
|
+
cli_base_url = _CLI_RESOURCE_BASE_URL.get()
|
|
167
|
+
if cli_base_url is None:
|
|
168
|
+
return get_web_base_url()
|
|
169
|
+
return _web_origin_from_api_base_url(cli_base_url)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _web_origin_from_api_base_url(value: str) -> str:
|
|
173
|
+
normalized = value.strip()
|
|
174
|
+
if not normalized:
|
|
175
|
+
return DEFAULT_WEB_BASE_URL
|
|
176
|
+
try:
|
|
177
|
+
parsed = urlsplit(normalized)
|
|
178
|
+
host = parsed.hostname or ""
|
|
179
|
+
valid_host = bool(host) and (":" in host or all(
|
|
180
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
181
|
+
for label in host.rstrip(".").split(".")
|
|
182
|
+
))
|
|
183
|
+
if (
|
|
184
|
+
parsed.scheme not in {"http", "https"}
|
|
185
|
+
or not valid_host
|
|
186
|
+
or host.lower() in {"null", "undefined"}
|
|
187
|
+
or parsed.username is not None
|
|
188
|
+
or parsed.password is not None
|
|
189
|
+
or parsed.query
|
|
190
|
+
or parsed.fragment
|
|
191
|
+
or any(character.isspace() or ord(character) < 32 for character in normalized)
|
|
192
|
+
):
|
|
193
|
+
raise ValueError
|
|
194
|
+
_ = parsed.port
|
|
195
|
+
return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
|
|
196
|
+
except ValueError:
|
|
197
|
+
raise QingflowApiError.config_error(
|
|
198
|
+
"QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
|
|
199
|
+
) from None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _identity_segment(value: object, field: str) -> str:
|
|
203
|
+
raw = _validate_identity(value, field)
|
|
204
|
+
return quote(raw, safe="")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _decode_segment(value: str, field: str) -> str:
|
|
208
|
+
decoded = _validate_identity(unquote(value, errors="strict"), field)
|
|
209
|
+
if quote(decoded, safe="") != value:
|
|
210
|
+
raise ValueError(f"{field} must use canonical path encoding")
|
|
211
|
+
return decoded
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _validate_identity(value: object, field: str) -> str:
|
|
215
|
+
source = str(value or "")
|
|
216
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in source):
|
|
217
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
218
|
+
raw = source.strip()
|
|
219
|
+
if not raw:
|
|
220
|
+
raise ValueError(f"{field} is required")
|
|
221
|
+
current = raw
|
|
222
|
+
for _ in range(8):
|
|
223
|
+
if (
|
|
224
|
+
current in {".", ".."}
|
|
225
|
+
or any(character in current for character in "/\\?#")
|
|
226
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
227
|
+
):
|
|
228
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
229
|
+
decoded = unquote(current, errors="strict")
|
|
230
|
+
if decoded == current:
|
|
231
|
+
return raw
|
|
232
|
+
current = decoded
|
|
233
|
+
if (
|
|
234
|
+
current in {".", ".."}
|
|
235
|
+
or any(character in current for character in "/\\?#")
|
|
236
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
237
|
+
):
|
|
238
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
239
|
+
if _ENCODED_OCTET.search(current):
|
|
240
|
+
raise ValueError(f"{field} has excessive encoding")
|
|
241
|
+
return raw
|