tinet-agent-cli 0.1.0.dev36__py3-none-any.whl
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.
- taco/__init__.py +1 -0
- taco/agent_openapi/__init__.py +1 -0
- taco/agent_openapi/catalog.py +198 -0
- taco/agent_openapi/models.py +47 -0
- taco/agent_openapi/parameters.py +111 -0
- taco/agent_openapi/service.py +73 -0
- taco/aikb/__init__.py +4 -0
- taco/aikb/catalog.py +380 -0
- taco/aikb/executors.py +157 -0
- taco/aikb/file_input.py +55 -0
- taco/aikb/models.py +42 -0
- taco/aikb/parameters.py +135 -0
- taco/aikb/service.py +68 -0
- taco/assets/__init__.py +1 -0
- taco/assets/agent_openapi/catalog.json +14 -0
- taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
- taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
- taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
- taco/assets/aikb/catalog.json +41 -0
- taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
- taco/assets/aikb/operations/directory/delete-directory.json +81 -0
- taco/assets/aikb/operations/directory/edit-directory.json +97 -0
- taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
- taco/assets/aikb/operations/directory/save-directory.json +109 -0
- taco/assets/aikb/operations/faq/create-faq.json +193 -0
- taco/assets/aikb/operations/faq/delete-faq.json +81 -0
- taco/assets/aikb/operations/faq/describe-faq.json +82 -0
- taco/assets/aikb/operations/faq/edit-faq.json +193 -0
- taco/assets/aikb/operations/faq/list-faqs.json +136 -0
- taco/assets/aikb/operations/file/create-file.json +145 -0
- taco/assets/aikb/operations/file/delete-files.json +110 -0
- taco/assets/aikb/operations/file/describe-file.json +82 -0
- taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
- taco/assets/aikb/operations/file/list-files.json +146 -0
- taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
- taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
- taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
- taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
- taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
- taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
- taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
- taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
- taco/assets/examples/README.md +3 -0
- taco/assets/examples/agent-basic.json +16 -0
- taco/assets/examples/agent-builtin-tool.json +28 -0
- taco/assets/examples/agent-workflow-tool.json +30 -0
- taco/assets/examples/chatflow-basic.json +24 -0
- taco/assets/examples/workflow-http.json +34 -0
- taco/assets/manifest.json +12 -0
- taco/assets/scenarios/README.md +3 -0
- taco/assets/scenarios/agent-basic.json +80 -0
- taco/assets/scenarios/agent-builtin-tool.json +116 -0
- taco/assets/scenarios/agent-workflow-tool.json +277 -0
- taco/assets/schemas/README.md +3 -0
- taco/assets/schemas/agent-tool.json +71 -0
- taco/assets/schemas/agent.json +199 -0
- taco/assets/schemas/chatflow.json +1048 -0
- taco/assets/schemas/workflow.http.json +1052 -0
- taco/assets/schemas/workflow.json +1052 -0
- taco/assets/skills/README.md +3 -0
- taco/assets/skills/taco/SKILL.md +68 -0
- taco/assets/skills/taco/manifest.json +10 -0
- taco/cli.py +127 -0
- taco/commands/__init__.py +1 -0
- taco/commands/agent.py +760 -0
- taco/commands/aikb.py +132 -0
- taco/commands/app.py +151 -0
- taco/commands/category.py +37 -0
- taco/commands/chatflow.py +183 -0
- taco/commands/credential.py +222 -0
- taco/commands/discovery.py +409 -0
- taco/commands/model.py +55 -0
- taco/commands/profile.py +124 -0
- taco/commands/tool.py +61 -0
- taco/commands/workflow.py +714 -0
- taco/core/__init__.py +1 -0
- taco/core/access_token_manager.py +98 -0
- taco/core/api_client.py +263 -0
- taco/core/assets.py +81 -0
- taco/core/config_store.py +209 -0
- taco/core/context.py +19 -0
- taco/core/developer_center_client.py +165 -0
- taco/core/errors.py +19 -0
- taco/core/file_lock.py +80 -0
- taco/core/http_headers.py +21 -0
- taco/core/openapi_signer.py +221 -0
- taco/core/output.py +72 -0
- taco/core/profile_manager.py +217 -0
- taco/core/profile_resolver.py +151 -0
- taco/core/secure_file.py +76 -0
- taco/release.py +363 -0
- taco/services/__init__.py +1 -0
- taco/services/agent_service.py +314 -0
- taco/services/app_service.py +153 -0
- taco/services/category_service.py +57 -0
- taco/services/model_service.py +117 -0
- taco/services/tool_service.py +482 -0
- taco/services/workflow_compiler.py +1665 -0
- taco/services/workflow_service.py +652 -0
- taco/services/workflow_tool_service.py +439 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable, Mapping
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from taco.core.api_client import ApiClient
|
|
8
|
+
from taco.core.errors import TacoError
|
|
9
|
+
from taco.services.workflow_compiler import summarize_graph
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DEFAULT_FEATURES = {"retriever_resource": {"enabled": True}}
|
|
13
|
+
_SAFE_APPLY_RESPONSE_FIELDS = ("result", "hash", "updated_at")
|
|
14
|
+
_HIDDEN_SECRET_VALUE = "[__HIDDEN__]"
|
|
15
|
+
_MAX_STORED_EVENTS = 1000
|
|
16
|
+
_SAFE_EVENT_TOP_LEVEL_FIELDS = ("event", "task_id", "workflow_run_id")
|
|
17
|
+
_SAFE_EVENT_DATA_FIELDS = (
|
|
18
|
+
"id",
|
|
19
|
+
"workflow_id",
|
|
20
|
+
"node_id",
|
|
21
|
+
"node_execution_id",
|
|
22
|
+
"node_type",
|
|
23
|
+
"status",
|
|
24
|
+
"index",
|
|
25
|
+
"sequence",
|
|
26
|
+
"sequence_number",
|
|
27
|
+
"iteration_id",
|
|
28
|
+
"iteration_index",
|
|
29
|
+
"loop_id",
|
|
30
|
+
"loop_index",
|
|
31
|
+
"parallel_id",
|
|
32
|
+
"parallel_start_node_id",
|
|
33
|
+
"predecessor_node_id",
|
|
34
|
+
"retry_index",
|
|
35
|
+
"created_at",
|
|
36
|
+
"started_at",
|
|
37
|
+
"finished_at",
|
|
38
|
+
"elapsed_time",
|
|
39
|
+
"total_steps",
|
|
40
|
+
"total_tokens",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WorkflowService:
|
|
45
|
+
def __init__(self, api_client: ApiClient | None = None) -> None:
|
|
46
|
+
self.api_client = api_client or ApiClient()
|
|
47
|
+
|
|
48
|
+
def create_workflow_app(
|
|
49
|
+
self,
|
|
50
|
+
name: str,
|
|
51
|
+
description: str,
|
|
52
|
+
profile: str | None = None,
|
|
53
|
+
*,
|
|
54
|
+
mode: str = "workflow",
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
if mode not in {"workflow", "advanced-chat"}:
|
|
57
|
+
raise TacoError(
|
|
58
|
+
code="WORKFLOW_MODE_INVALID",
|
|
59
|
+
message="应用 mode 仅支持 workflow 或 advanced-chat。",
|
|
60
|
+
exit_code=2,
|
|
61
|
+
)
|
|
62
|
+
icon = "💬" if mode == "advanced-chat" else "🔀"
|
|
63
|
+
app = self.api_client.post(
|
|
64
|
+
"/apps",
|
|
65
|
+
json={
|
|
66
|
+
"name": name,
|
|
67
|
+
"description": description,
|
|
68
|
+
"mode": mode,
|
|
69
|
+
"icon_type": "emoji",
|
|
70
|
+
"icon": icon,
|
|
71
|
+
"icon_background": "#E4FBCC",
|
|
72
|
+
},
|
|
73
|
+
profile=profile,
|
|
74
|
+
)
|
|
75
|
+
if (
|
|
76
|
+
not isinstance(app, dict)
|
|
77
|
+
or not isinstance(app.get("id"), str)
|
|
78
|
+
or not app["id"].strip()
|
|
79
|
+
):
|
|
80
|
+
raise TacoError(
|
|
81
|
+
code="WORKFLOW_CREATE_RESPONSE_INVALID",
|
|
82
|
+
message="创建 Workflow 的响应缺少有效应用 ID。",
|
|
83
|
+
exit_code=4,
|
|
84
|
+
hint="请检查 Console API 版本和响应格式。",
|
|
85
|
+
)
|
|
86
|
+
return app
|
|
87
|
+
|
|
88
|
+
def get_app(
|
|
89
|
+
self,
|
|
90
|
+
app_id: str,
|
|
91
|
+
profile: str | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
app = self.api_client.get(f"/apps/{app_id}", profile=profile)
|
|
94
|
+
if not isinstance(app, dict):
|
|
95
|
+
raise TacoError(
|
|
96
|
+
code="WORKFLOW_APP_RESPONSE_INVALID",
|
|
97
|
+
message="读取应用详情的响应格式不合法。",
|
|
98
|
+
exit_code=4,
|
|
99
|
+
)
|
|
100
|
+
return app
|
|
101
|
+
|
|
102
|
+
def get_draft(
|
|
103
|
+
self,
|
|
104
|
+
app_id: str,
|
|
105
|
+
profile: str | None = None,
|
|
106
|
+
) -> dict[str, Any] | None:
|
|
107
|
+
try:
|
|
108
|
+
draft = self.api_client.get(
|
|
109
|
+
f"/apps/{app_id}/workflows/draft",
|
|
110
|
+
profile=profile,
|
|
111
|
+
)
|
|
112
|
+
except TacoError as error:
|
|
113
|
+
if (
|
|
114
|
+
error.status == 400
|
|
115
|
+
and error.remote_code == "draft_workflow_not_exist"
|
|
116
|
+
):
|
|
117
|
+
return None
|
|
118
|
+
raise
|
|
119
|
+
if not isinstance(draft, dict):
|
|
120
|
+
raise _invalid_draft_response()
|
|
121
|
+
return draft
|
|
122
|
+
|
|
123
|
+
def plan_apply(
|
|
124
|
+
self,
|
|
125
|
+
app_id: str,
|
|
126
|
+
graph: Mapping[str, Any],
|
|
127
|
+
profile: str | None = None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
proposed_graph = _validated_graph(graph)
|
|
130
|
+
draft = self.get_draft(app_id, profile=profile)
|
|
131
|
+
current_graph = None if draft is None else _draft_graph(draft)
|
|
132
|
+
return {
|
|
133
|
+
"app_id": app_id,
|
|
134
|
+
"action": "create" if draft is None else "update",
|
|
135
|
+
"changed": (
|
|
136
|
+
True
|
|
137
|
+
if current_graph is None
|
|
138
|
+
else _canonical_graph(current_graph)
|
|
139
|
+
!= _canonical_graph(proposed_graph)
|
|
140
|
+
),
|
|
141
|
+
"current": (
|
|
142
|
+
None if current_graph is None else summarize_graph(current_graph)
|
|
143
|
+
),
|
|
144
|
+
"proposed": summarize_graph(proposed_graph),
|
|
145
|
+
"draft_hash": draft.get("hash") if draft is not None else None,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def apply_draft(
|
|
149
|
+
self,
|
|
150
|
+
app_id: str,
|
|
151
|
+
graph: Mapping[str, Any],
|
|
152
|
+
profile: str | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
proposed_graph = _validated_graph(graph)
|
|
155
|
+
initial_draft = self.get_draft(app_id, profile=profile)
|
|
156
|
+
initial_snapshot = _draft_metadata_snapshot(initial_draft)
|
|
157
|
+
_build_apply_payload(proposed_graph, initial_draft)
|
|
158
|
+
|
|
159
|
+
confirmed_draft = self.get_draft(app_id, profile=profile)
|
|
160
|
+
_verify_draft_metadata_unchanged(
|
|
161
|
+
initial_snapshot,
|
|
162
|
+
confirmed_draft,
|
|
163
|
+
)
|
|
164
|
+
payload = _build_apply_payload(proposed_graph, confirmed_draft)
|
|
165
|
+
|
|
166
|
+
# Dify metadata has no CAS token. This second read only narrows the race
|
|
167
|
+
# window; the hash sent below still provides server-side graph/features CAS.
|
|
168
|
+
response = self.api_client.post(
|
|
169
|
+
f"/apps/{app_id}/workflows/draft",
|
|
170
|
+
json=payload,
|
|
171
|
+
profile=profile,
|
|
172
|
+
)
|
|
173
|
+
if not isinstance(response, dict):
|
|
174
|
+
raise TacoError(
|
|
175
|
+
code="WORKFLOW_APPLY_RESPONSE_INVALID",
|
|
176
|
+
message="同步 Workflow draft 的响应格式不合法。",
|
|
177
|
+
exit_code=4,
|
|
178
|
+
hint="请检查 Console API 版本和响应格式。",
|
|
179
|
+
)
|
|
180
|
+
result = {
|
|
181
|
+
"app_id": app_id,
|
|
182
|
+
"applied": True,
|
|
183
|
+
"graph_summary": summarize_graph(proposed_graph),
|
|
184
|
+
}
|
|
185
|
+
for key in _SAFE_APPLY_RESPONSE_FIELDS:
|
|
186
|
+
value = response.get(key)
|
|
187
|
+
if key in response and _is_safe_metadata_value(value):
|
|
188
|
+
result[key] = value
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def run_draft(
|
|
192
|
+
self,
|
|
193
|
+
app_id: str,
|
|
194
|
+
inputs: dict[str, Any],
|
|
195
|
+
on_event: Callable[[dict[str, Any]], None] | None = None,
|
|
196
|
+
profile: str | None = None,
|
|
197
|
+
*,
|
|
198
|
+
query: str | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
if not isinstance(inputs, dict):
|
|
201
|
+
raise TacoError(
|
|
202
|
+
code="WORKFLOW_INPUTS_INVALID",
|
|
203
|
+
message="Workflow inputs 必须是 JSON 对象。",
|
|
204
|
+
exit_code=2,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
app = self.get_app(app_id, profile=profile)
|
|
208
|
+
mode = app.get("mode")
|
|
209
|
+
if mode == "advanced-chat":
|
|
210
|
+
run_path = f"/apps/{app_id}/advanced-chat/workflows/draft/run"
|
|
211
|
+
payload: dict[str, Any] = {
|
|
212
|
+
"inputs": inputs,
|
|
213
|
+
"query": query if query is not None else "",
|
|
214
|
+
"files": [],
|
|
215
|
+
}
|
|
216
|
+
else:
|
|
217
|
+
run_path = f"/apps/{app_id}/workflows/draft/run"
|
|
218
|
+
payload = {"inputs": inputs, "files": []}
|
|
219
|
+
|
|
220
|
+
stream = self.api_client.stream(
|
|
221
|
+
"POST",
|
|
222
|
+
run_path,
|
|
223
|
+
json=payload,
|
|
224
|
+
profile=profile,
|
|
225
|
+
)
|
|
226
|
+
events: list[dict[str, Any]] = []
|
|
227
|
+
events_truncated = False
|
|
228
|
+
identifiers: dict[str, Any] = {}
|
|
229
|
+
finished_data: dict[str, Any] | None = None
|
|
230
|
+
try:
|
|
231
|
+
for sse_event in stream:
|
|
232
|
+
event = _normalize_sse_event(sse_event)
|
|
233
|
+
event_type = event.get("event")
|
|
234
|
+
if event_type == "error":
|
|
235
|
+
raise _run_failed_error()
|
|
236
|
+
|
|
237
|
+
if event_type == "workflow_started":
|
|
238
|
+
for key in ("task_id", "workflow_run_id"):
|
|
239
|
+
if _is_safe_metadata_value(event.get(key)) and event.get(key):
|
|
240
|
+
identifiers[key] = event[key]
|
|
241
|
+
|
|
242
|
+
if event_type == "workflow_finished":
|
|
243
|
+
data = event.get("data")
|
|
244
|
+
finished_data = dict(data) if isinstance(data, dict) else {}
|
|
245
|
+
status = finished_data.get("status")
|
|
246
|
+
if status not in {"succeeded", "success"}:
|
|
247
|
+
raise _run_failed_error()
|
|
248
|
+
|
|
249
|
+
safe_event = _safe_sse_event(event)
|
|
250
|
+
if len(events) < _MAX_STORED_EVENTS:
|
|
251
|
+
events.append(safe_event)
|
|
252
|
+
else:
|
|
253
|
+
events_truncated = True
|
|
254
|
+
if on_event is not None:
|
|
255
|
+
on_event(safe_event)
|
|
256
|
+
if finished_data is not None:
|
|
257
|
+
break
|
|
258
|
+
except TacoError as error:
|
|
259
|
+
if error.code == "WORKFLOW_RUN_FAILED" or _is_local_preflight_error(
|
|
260
|
+
error
|
|
261
|
+
):
|
|
262
|
+
raise
|
|
263
|
+
raise _run_failed_error() from None
|
|
264
|
+
finally:
|
|
265
|
+
close = getattr(stream, "close", None)
|
|
266
|
+
if callable(close):
|
|
267
|
+
close()
|
|
268
|
+
|
|
269
|
+
if finished_data is None:
|
|
270
|
+
raise TacoError(
|
|
271
|
+
code="WORKFLOW_RUN_INCOMPLETE",
|
|
272
|
+
message="Workflow 调试流在 workflow_finished 前结束。",
|
|
273
|
+
exit_code=5,
|
|
274
|
+
hint="请检查网络和服务端日志后重试。",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
result = _safe_sse_data(finished_data)
|
|
278
|
+
if "outputs" in finished_data:
|
|
279
|
+
result["outputs"] = finished_data["outputs"]
|
|
280
|
+
result.update(identifiers)
|
|
281
|
+
result["events"] = events
|
|
282
|
+
result["events_truncated"] = events_truncated
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
def stop_run(
|
|
286
|
+
self,
|
|
287
|
+
app_id: str,
|
|
288
|
+
task_id: str,
|
|
289
|
+
profile: str | None = None,
|
|
290
|
+
) -> Any:
|
|
291
|
+
return self.api_client.post(
|
|
292
|
+
f"/apps/{app_id}/workflow-runs/tasks/{task_id}/stop",
|
|
293
|
+
profile=profile,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def publish(
|
|
297
|
+
self,
|
|
298
|
+
app_id: str,
|
|
299
|
+
title: str | None = None,
|
|
300
|
+
notes: str | None = None,
|
|
301
|
+
profile: str | None = None,
|
|
302
|
+
) -> Any:
|
|
303
|
+
return self.api_client.post(
|
|
304
|
+
f"/apps/{app_id}/workflows/publish",
|
|
305
|
+
json={
|
|
306
|
+
"marked_name": title or "",
|
|
307
|
+
"marked_comment": notes or "",
|
|
308
|
+
},
|
|
309
|
+
profile=profile,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def set_secret(
|
|
313
|
+
self,
|
|
314
|
+
app_id: str,
|
|
315
|
+
name: str,
|
|
316
|
+
value: str,
|
|
317
|
+
profile: str | None = None,
|
|
318
|
+
) -> dict[str, str]:
|
|
319
|
+
if value == _HIDDEN_SECRET_VALUE:
|
|
320
|
+
raise TacoError(
|
|
321
|
+
code="WORKFLOW_SECRET_VALUE_RESERVED",
|
|
322
|
+
message="该 Workflow secret 值是 Dify 保留占位符,不能用于更新。",
|
|
323
|
+
exit_code=2,
|
|
324
|
+
hint="请提供真实的新 secret 值。",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
draft = self.get_draft(app_id, profile=profile)
|
|
329
|
+
except TacoError as error:
|
|
330
|
+
if _is_local_preflight_error(error):
|
|
331
|
+
raise
|
|
332
|
+
raise _secret_update_failed_error() from None
|
|
333
|
+
|
|
334
|
+
if draft is None:
|
|
335
|
+
raise TacoError(
|
|
336
|
+
code="WORKFLOW_DRAFT_REQUIRED",
|
|
337
|
+
message="Workflow 尚无 draft,请先执行 workflow apply。",
|
|
338
|
+
exit_code=6,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
initial_snapshot = _draft_metadata_snapshot(draft)
|
|
343
|
+
_, action, new_secret_id = _build_secret_update_payload(
|
|
344
|
+
draft,
|
|
345
|
+
name,
|
|
346
|
+
value,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
confirmed_draft = self.get_draft(app_id, profile=profile)
|
|
350
|
+
_verify_draft_metadata_unchanged(
|
|
351
|
+
initial_snapshot,
|
|
352
|
+
confirmed_draft,
|
|
353
|
+
)
|
|
354
|
+
if confirmed_draft is None:
|
|
355
|
+
raise _draft_out_of_sync_error()
|
|
356
|
+
payload, action, _ = _build_secret_update_payload(
|
|
357
|
+
confirmed_draft,
|
|
358
|
+
name,
|
|
359
|
+
value,
|
|
360
|
+
new_secret_id=new_secret_id,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# The hash protects graph/features on POST. Dify offers no metadata
|
|
364
|
+
# CAS, so environment/conversation changes after this GET remain possible.
|
|
365
|
+
self.api_client.post(
|
|
366
|
+
f"/apps/{app_id}/workflows/draft",
|
|
367
|
+
json=payload,
|
|
368
|
+
profile=profile,
|
|
369
|
+
)
|
|
370
|
+
except TacoError as error:
|
|
371
|
+
if error.code in {
|
|
372
|
+
"WORKFLOW_SECRET_NAME_CONFLICT",
|
|
373
|
+
"WORKFLOW_DRAFT_OUT_OF_SYNC",
|
|
374
|
+
}:
|
|
375
|
+
raise
|
|
376
|
+
if _is_local_preflight_error(error):
|
|
377
|
+
raise
|
|
378
|
+
raise _secret_update_failed_error() from None
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
"app_id": app_id,
|
|
382
|
+
"name": name,
|
|
383
|
+
"action": action,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _validated_graph(graph: Mapping[str, Any]) -> dict[str, Any]:
|
|
388
|
+
if not isinstance(graph, dict):
|
|
389
|
+
raise TacoError(
|
|
390
|
+
code="WORKFLOW_GRAPH_INVALID",
|
|
391
|
+
message="Workflow graph 必须是对象。",
|
|
392
|
+
exit_code=6,
|
|
393
|
+
)
|
|
394
|
+
_canonical_graph(graph)
|
|
395
|
+
return graph
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _canonical_graph(graph: Mapping[str, Any]) -> str:
|
|
399
|
+
try:
|
|
400
|
+
return json.dumps(
|
|
401
|
+
graph,
|
|
402
|
+
ensure_ascii=False,
|
|
403
|
+
sort_keys=True,
|
|
404
|
+
separators=(",", ":"),
|
|
405
|
+
allow_nan=False,
|
|
406
|
+
)
|
|
407
|
+
except (TypeError, ValueError, RecursionError) as error:
|
|
408
|
+
raise TacoError(
|
|
409
|
+
code="WORKFLOW_GRAPH_INVALID",
|
|
410
|
+
message="Workflow graph 不是合法 JSON 对象。",
|
|
411
|
+
exit_code=6,
|
|
412
|
+
) from error
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_apply_payload(
|
|
416
|
+
graph: dict[str, Any],
|
|
417
|
+
draft: dict[str, Any] | None,
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
if draft is None:
|
|
420
|
+
return {
|
|
421
|
+
"graph": graph,
|
|
422
|
+
"features": _DEFAULT_FEATURES,
|
|
423
|
+
"environment_variables": [],
|
|
424
|
+
"conversation_variables": [],
|
|
425
|
+
}
|
|
426
|
+
payload = {
|
|
427
|
+
"graph": graph,
|
|
428
|
+
"features": _draft_mapping(draft, "features"),
|
|
429
|
+
"environment_variables": _safe_environment_variables(
|
|
430
|
+
_draft_environment_variables(draft),
|
|
431
|
+
),
|
|
432
|
+
"conversation_variables": _draft_list(
|
|
433
|
+
draft,
|
|
434
|
+
"conversation_variables",
|
|
435
|
+
),
|
|
436
|
+
}
|
|
437
|
+
if draft.get("hash") is not None:
|
|
438
|
+
payload["hash"] = draft["hash"]
|
|
439
|
+
return payload
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _build_secret_update_payload(
|
|
443
|
+
draft: dict[str, Any],
|
|
444
|
+
name: str,
|
|
445
|
+
value: str,
|
|
446
|
+
*,
|
|
447
|
+
new_secret_id: str | None = None,
|
|
448
|
+
) -> tuple[dict[str, Any], str, str | None]:
|
|
449
|
+
graph = _draft_graph(draft)
|
|
450
|
+
features = _draft_mapping(draft, "features")
|
|
451
|
+
environment_variables = _draft_environment_variables(draft)
|
|
452
|
+
conversation_variables = _draft_list(draft, "conversation_variables")
|
|
453
|
+
matching = [
|
|
454
|
+
variable
|
|
455
|
+
for variable in environment_variables
|
|
456
|
+
if variable.get("name") == name
|
|
457
|
+
]
|
|
458
|
+
if len(matching) > 1 or any(
|
|
459
|
+
variable.get("value_type") != "secret" for variable in matching
|
|
460
|
+
):
|
|
461
|
+
raise TacoError(
|
|
462
|
+
code="WORKFLOW_SECRET_NAME_CONFLICT",
|
|
463
|
+
message="同名环境变量不唯一或不是 secret。",
|
|
464
|
+
exit_code=6,
|
|
465
|
+
hint="请更换 secret 名称,或先在 Dify 中处理同名变量。",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
action = "updated" if matching else "created"
|
|
469
|
+
if matching:
|
|
470
|
+
updated_variables = _safe_environment_variables(
|
|
471
|
+
environment_variables,
|
|
472
|
+
target=matching[0],
|
|
473
|
+
target_value=value,
|
|
474
|
+
)
|
|
475
|
+
else:
|
|
476
|
+
if new_secret_id is None:
|
|
477
|
+
new_secret_id = str(uuid4())
|
|
478
|
+
updated_variables = [
|
|
479
|
+
*_safe_environment_variables(environment_variables),
|
|
480
|
+
{
|
|
481
|
+
"id": new_secret_id,
|
|
482
|
+
"name": name,
|
|
483
|
+
"value": value,
|
|
484
|
+
"value_type": "secret",
|
|
485
|
+
},
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
payload: dict[str, Any] = {
|
|
489
|
+
"graph": graph,
|
|
490
|
+
"features": features,
|
|
491
|
+
"environment_variables": updated_variables,
|
|
492
|
+
"conversation_variables": conversation_variables,
|
|
493
|
+
}
|
|
494
|
+
if draft.get("hash") is not None:
|
|
495
|
+
payload["hash"] = draft["hash"]
|
|
496
|
+
return payload, action, new_secret_id
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _draft_metadata_snapshot(draft: dict[str, Any] | None) -> str | None:
|
|
500
|
+
if draft is None:
|
|
501
|
+
return None
|
|
502
|
+
metadata = {
|
|
503
|
+
"hash_present": "hash" in draft,
|
|
504
|
+
"hash": draft.get("hash"),
|
|
505
|
+
"environment_variables": _draft_environment_variables(draft),
|
|
506
|
+
"conversation_variables": _draft_list(draft, "conversation_variables"),
|
|
507
|
+
}
|
|
508
|
+
try:
|
|
509
|
+
return json.dumps(
|
|
510
|
+
metadata,
|
|
511
|
+
ensure_ascii=False,
|
|
512
|
+
sort_keys=True,
|
|
513
|
+
separators=(",", ":"),
|
|
514
|
+
allow_nan=False,
|
|
515
|
+
)
|
|
516
|
+
except (TypeError, ValueError, RecursionError) as error:
|
|
517
|
+
raise _invalid_draft_response() from error
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _verify_draft_metadata_unchanged(
|
|
521
|
+
initial_snapshot: str | None,
|
|
522
|
+
confirmed_draft: dict[str, Any] | None,
|
|
523
|
+
) -> None:
|
|
524
|
+
if initial_snapshot != _draft_metadata_snapshot(confirmed_draft):
|
|
525
|
+
raise _draft_out_of_sync_error()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _draft_graph(draft: dict[str, Any]) -> dict[str, Any]:
|
|
529
|
+
graph = draft.get("graph")
|
|
530
|
+
if not isinstance(graph, dict):
|
|
531
|
+
raise _invalid_draft_response()
|
|
532
|
+
return graph
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _draft_mapping(draft: dict[str, Any], field: str) -> dict[str, Any]:
|
|
536
|
+
value = draft.get(field)
|
|
537
|
+
if not isinstance(value, dict):
|
|
538
|
+
raise _invalid_draft_response()
|
|
539
|
+
return value
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _draft_list(draft: dict[str, Any], field: str) -> list[Any]:
|
|
543
|
+
value = draft.get(field)
|
|
544
|
+
if not isinstance(value, list):
|
|
545
|
+
raise _invalid_draft_response()
|
|
546
|
+
return value
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _draft_environment_variables(
|
|
550
|
+
draft: dict[str, Any],
|
|
551
|
+
) -> list[dict[str, Any]]:
|
|
552
|
+
variables = _draft_list(draft, "environment_variables")
|
|
553
|
+
if any(not isinstance(variable, dict) for variable in variables):
|
|
554
|
+
raise _invalid_draft_response()
|
|
555
|
+
return variables
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _safe_environment_variables(
|
|
559
|
+
variables: list[dict[str, Any]],
|
|
560
|
+
*,
|
|
561
|
+
target: dict[str, Any] | None = None,
|
|
562
|
+
target_value: str | None = None,
|
|
563
|
+
) -> list[dict[str, Any]]:
|
|
564
|
+
safe_variables: list[dict[str, Any]] = []
|
|
565
|
+
for variable in variables:
|
|
566
|
+
if variable.get("value_type") != "secret":
|
|
567
|
+
safe_variables.append(variable)
|
|
568
|
+
continue
|
|
569
|
+
safe_variable = dict(variable)
|
|
570
|
+
safe_variable["value"] = (
|
|
571
|
+
target_value if variable is target else _HIDDEN_SECRET_VALUE
|
|
572
|
+
)
|
|
573
|
+
safe_variables.append(safe_variable)
|
|
574
|
+
return safe_variables
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_safe_metadata_value(value: Any) -> bool:
|
|
578
|
+
return value is None or isinstance(value, (str, bool, int, float))
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _normalize_sse_event(sse_event: Any) -> dict[str, Any]:
|
|
582
|
+
if isinstance(sse_event.data, dict):
|
|
583
|
+
event = dict(sse_event.data)
|
|
584
|
+
else:
|
|
585
|
+
event = {"data": sse_event.data}
|
|
586
|
+
if sse_event.event and "event" not in event:
|
|
587
|
+
event["event"] = sse_event.event
|
|
588
|
+
if sse_event.id is not None:
|
|
589
|
+
event["sse_id"] = sse_event.id
|
|
590
|
+
return event
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _safe_sse_event(event: dict[str, Any]) -> dict[str, Any]:
|
|
594
|
+
safe_event = {
|
|
595
|
+
key: event[key]
|
|
596
|
+
for key in _SAFE_EVENT_TOP_LEVEL_FIELDS
|
|
597
|
+
if key in event and _is_safe_metadata_value(event[key])
|
|
598
|
+
}
|
|
599
|
+
data = event.get("data")
|
|
600
|
+
if isinstance(data, dict):
|
|
601
|
+
safe_data = _safe_sse_data(data)
|
|
602
|
+
if safe_data:
|
|
603
|
+
safe_event["data"] = safe_data
|
|
604
|
+
return safe_event
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _safe_sse_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
608
|
+
return {
|
|
609
|
+
key: data[key]
|
|
610
|
+
for key in _SAFE_EVENT_DATA_FIELDS
|
|
611
|
+
if key in data and _is_safe_metadata_value(data[key])
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _run_failed_error() -> TacoError:
|
|
616
|
+
return TacoError(
|
|
617
|
+
code="WORKFLOW_RUN_FAILED",
|
|
618
|
+
message="Workflow draft 调试失败。",
|
|
619
|
+
exit_code=5,
|
|
620
|
+
hint="请检查 draft 配置和服务端日志。",
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _secret_update_failed_error() -> TacoError:
|
|
625
|
+
return TacoError(
|
|
626
|
+
code="WORKFLOW_SECRET_UPDATE_FAILED",
|
|
627
|
+
message="Workflow secret 更新失败。",
|
|
628
|
+
exit_code=4,
|
|
629
|
+
hint="请检查认证、网络和 draft 状态后重试。",
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _draft_out_of_sync_error() -> TacoError:
|
|
634
|
+
return TacoError(
|
|
635
|
+
code="WORKFLOW_DRAFT_OUT_OF_SYNC",
|
|
636
|
+
message="Workflow draft 在写入前已发生变化,本次未同步。",
|
|
637
|
+
exit_code=7,
|
|
638
|
+
hint="请重新获取 draft 后重试。",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _is_local_preflight_error(error: TacoError) -> bool:
|
|
643
|
+
return error.code.startswith(("AUTH_", "CONFIG_"))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _invalid_draft_response() -> TacoError:
|
|
647
|
+
return TacoError(
|
|
648
|
+
code="WORKFLOW_DRAFT_RESPONSE_INVALID",
|
|
649
|
+
message="Workflow draft 响应格式不合法。",
|
|
650
|
+
exit_code=4,
|
|
651
|
+
hint="请检查 Console API 版本和响应格式。",
|
|
652
|
+
)
|