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.
Files changed (104) hide show
  1. taco/__init__.py +1 -0
  2. taco/agent_openapi/__init__.py +1 -0
  3. taco/agent_openapi/catalog.py +198 -0
  4. taco/agent_openapi/models.py +47 -0
  5. taco/agent_openapi/parameters.py +111 -0
  6. taco/agent_openapi/service.py +73 -0
  7. taco/aikb/__init__.py +4 -0
  8. taco/aikb/catalog.py +380 -0
  9. taco/aikb/executors.py +157 -0
  10. taco/aikb/file_input.py +55 -0
  11. taco/aikb/models.py +42 -0
  12. taco/aikb/parameters.py +135 -0
  13. taco/aikb/service.py +68 -0
  14. taco/assets/__init__.py +1 -0
  15. taco/assets/agent_openapi/catalog.json +14 -0
  16. taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
  17. taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
  18. taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
  19. taco/assets/aikb/catalog.json +41 -0
  20. taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
  21. taco/assets/aikb/operations/directory/delete-directory.json +81 -0
  22. taco/assets/aikb/operations/directory/edit-directory.json +97 -0
  23. taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
  24. taco/assets/aikb/operations/directory/save-directory.json +109 -0
  25. taco/assets/aikb/operations/faq/create-faq.json +193 -0
  26. taco/assets/aikb/operations/faq/delete-faq.json +81 -0
  27. taco/assets/aikb/operations/faq/describe-faq.json +82 -0
  28. taco/assets/aikb/operations/faq/edit-faq.json +193 -0
  29. taco/assets/aikb/operations/faq/list-faqs.json +136 -0
  30. taco/assets/aikb/operations/file/create-file.json +145 -0
  31. taco/assets/aikb/operations/file/delete-files.json +110 -0
  32. taco/assets/aikb/operations/file/describe-file.json +82 -0
  33. taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
  34. taco/assets/aikb/operations/file/list-files.json +146 -0
  35. taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
  36. taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
  37. taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
  38. taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
  39. taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
  40. taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
  41. taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
  42. taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
  43. taco/assets/examples/README.md +3 -0
  44. taco/assets/examples/agent-basic.json +16 -0
  45. taco/assets/examples/agent-builtin-tool.json +28 -0
  46. taco/assets/examples/agent-workflow-tool.json +30 -0
  47. taco/assets/examples/chatflow-basic.json +24 -0
  48. taco/assets/examples/workflow-http.json +34 -0
  49. taco/assets/manifest.json +12 -0
  50. taco/assets/scenarios/README.md +3 -0
  51. taco/assets/scenarios/agent-basic.json +80 -0
  52. taco/assets/scenarios/agent-builtin-tool.json +116 -0
  53. taco/assets/scenarios/agent-workflow-tool.json +277 -0
  54. taco/assets/schemas/README.md +3 -0
  55. taco/assets/schemas/agent-tool.json +71 -0
  56. taco/assets/schemas/agent.json +199 -0
  57. taco/assets/schemas/chatflow.json +1048 -0
  58. taco/assets/schemas/workflow.http.json +1052 -0
  59. taco/assets/schemas/workflow.json +1052 -0
  60. taco/assets/skills/README.md +3 -0
  61. taco/assets/skills/taco/SKILL.md +68 -0
  62. taco/assets/skills/taco/manifest.json +10 -0
  63. taco/cli.py +127 -0
  64. taco/commands/__init__.py +1 -0
  65. taco/commands/agent.py +760 -0
  66. taco/commands/aikb.py +132 -0
  67. taco/commands/app.py +151 -0
  68. taco/commands/category.py +37 -0
  69. taco/commands/chatflow.py +183 -0
  70. taco/commands/credential.py +222 -0
  71. taco/commands/discovery.py +409 -0
  72. taco/commands/model.py +55 -0
  73. taco/commands/profile.py +124 -0
  74. taco/commands/tool.py +61 -0
  75. taco/commands/workflow.py +714 -0
  76. taco/core/__init__.py +1 -0
  77. taco/core/access_token_manager.py +98 -0
  78. taco/core/api_client.py +263 -0
  79. taco/core/assets.py +81 -0
  80. taco/core/config_store.py +209 -0
  81. taco/core/context.py +19 -0
  82. taco/core/developer_center_client.py +165 -0
  83. taco/core/errors.py +19 -0
  84. taco/core/file_lock.py +80 -0
  85. taco/core/http_headers.py +21 -0
  86. taco/core/openapi_signer.py +221 -0
  87. taco/core/output.py +72 -0
  88. taco/core/profile_manager.py +217 -0
  89. taco/core/profile_resolver.py +151 -0
  90. taco/core/secure_file.py +76 -0
  91. taco/release.py +363 -0
  92. taco/services/__init__.py +1 -0
  93. taco/services/agent_service.py +314 -0
  94. taco/services/app_service.py +153 -0
  95. taco/services/category_service.py +57 -0
  96. taco/services/model_service.py +117 -0
  97. taco/services/tool_service.py +482 -0
  98. taco/services/workflow_compiler.py +1665 -0
  99. taco/services/workflow_service.py +652 -0
  100. taco/services/workflow_tool_service.py +439 -0
  101. tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
  102. tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
  103. tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
  104. 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
+ )