dynworkflow 0.1.0__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.
- dynworkflow/__init__.py +19 -0
- dynworkflow/agent.py +1022 -0
- dynworkflow/cache.py +292 -0
- dynworkflow/flow.py +702 -0
- dynworkflow/py.typed +0 -0
- dynworkflow/reporter.py +250 -0
- dynworkflow/schemas.py +88 -0
- dynworkflow-0.1.0.dist-info/METADATA +93 -0
- dynworkflow-0.1.0.dist-info/RECORD +10 -0
- dynworkflow-0.1.0.dist-info/WHEEL +4 -0
dynworkflow/cache.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""WorkflowCache 工作流 SQLite 持久缓存。
|
|
3
|
+
|
|
4
|
+
缓存内容分两类:
|
|
5
|
+
- ``agent_cache``:Agent 运行结果(MultiAgent 按单个 agent 分别缓存),
|
|
6
|
+
以及未正常结束会话的 ACP sessionId(供下次按需 ACP load 续跑);
|
|
7
|
+
- ``node_cache``:节点执行结果——终值(Result/None)或下游调度描述
|
|
8
|
+
(Execute 链序列化为 [目标节点 id + 调用时参数],命中时重建重放)。
|
|
9
|
+
|
|
10
|
+
联合键统一为 [会话作用域 + 工作流名 + 节点 ID + 载荷 hash];节点载荷
|
|
11
|
+
包含函数源码体 hash(代码变更即失效)与全部有效参数。
|
|
12
|
+
任一成分无法 JSON 序列化时该条目不读也不写。
|
|
13
|
+
所有 sqlite 故障都降级为“无缓存”,绝不影响工作流运行。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import sqlite3
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
DEFAULT_CACHE_DIRNAME = ".alkaid0"
|
|
30
|
+
DEFAULT_DB_FILENAME = "workflow.db"
|
|
31
|
+
|
|
32
|
+
_SCHEMA = """
|
|
33
|
+
CREATE TABLE IF NOT EXISTS agent_cache (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
kind TEXT NOT NULL CHECK(kind IN ('result', 'session')),
|
|
36
|
+
value TEXT NOT NULL,
|
|
37
|
+
session_scope TEXT NOT NULL DEFAULT '',
|
|
38
|
+
workflow TEXT NOT NULL,
|
|
39
|
+
node_id TEXT NOT NULL,
|
|
40
|
+
payload_hash TEXT NOT NULL,
|
|
41
|
+
updated_at REAL NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS node_cache (
|
|
44
|
+
key TEXT PRIMARY KEY,
|
|
45
|
+
value TEXT NOT NULL,
|
|
46
|
+
session_scope TEXT NOT NULL DEFAULT '',
|
|
47
|
+
workflow TEXT NOT NULL,
|
|
48
|
+
node_id TEXT NOT NULL,
|
|
49
|
+
payload_hash TEXT NOT NULL,
|
|
50
|
+
updated_at REAL NOT NULL
|
|
51
|
+
);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_db_path() -> pathlib.Path:
|
|
56
|
+
"""default_db_path() 当前工作目录下的默认缓存库路径 .alkaid0/workflow.db"""
|
|
57
|
+
return pathlib.Path.cwd() / DEFAULT_CACHE_DIRNAME / DEFAULT_DB_FILENAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def canonical_json(value: Any) -> str:
|
|
61
|
+
"""canonical_json() 确定性 JSON 序列化,作为哈希与存储的规范形式"""
|
|
62
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False,
|
|
63
|
+
separators=(",", ":"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def try_hash(value: Any) -> str | None:
|
|
67
|
+
"""try_hash() 值可 JSON 序列化时返回 sha256,否则返回 None(不可序列化)"""
|
|
68
|
+
try:
|
|
69
|
+
payload = canonical_json(value)
|
|
70
|
+
except (TypeError, ValueError, OverflowError):
|
|
71
|
+
return None
|
|
72
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _try_serialize(value: Any) -> str | None:
|
|
76
|
+
"""_try_serialize() 可序列化则返回规范 JSON 文本,否则 None"""
|
|
77
|
+
try:
|
|
78
|
+
return canonical_json(value)
|
|
79
|
+
except (TypeError, ValueError, OverflowError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def union_key(session_scope: str, workflow: str, node_id: str,
|
|
84
|
+
payload_hash: str) -> str:
|
|
85
|
+
"""union_key() [会话作用域+工作流名+节点ID+载荷hash] 的联合键"""
|
|
86
|
+
joined = "\x1f".join((session_scope, workflow, node_id, payload_hash))
|
|
87
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class CacheKey:
|
|
92
|
+
"""CacheKey 联合键及其组成成分(成分写入观测列,便于排查)"""
|
|
93
|
+
|
|
94
|
+
key: str
|
|
95
|
+
session_scope: str
|
|
96
|
+
workflow: str
|
|
97
|
+
node_id: str
|
|
98
|
+
payload_hash: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class CacheContext:
|
|
103
|
+
"""CacheContext 单次节点执行对应的缓存上下文
|
|
104
|
+
|
|
105
|
+
``code_hash`` 为节点函数源码体的 sha256:函数体变更即产生新键,
|
|
106
|
+
旧缓存自动失效。为 None(源码不可得)时不做节点缓存。
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
cache: WorkflowCache | None
|
|
110
|
+
workflow: str
|
|
111
|
+
node_id: str
|
|
112
|
+
session_scope: str
|
|
113
|
+
code_hash: str | None = None
|
|
114
|
+
|
|
115
|
+
def agent_key(self, prompt: str, cwd: str) -> CacheKey | None:
|
|
116
|
+
"""agent_key() Agent 运行的联合键(prompt+workdir 拼合 hash)"""
|
|
117
|
+
return self._make_key(try_hash({"prompt": prompt, "cwd": cwd}))
|
|
118
|
+
|
|
119
|
+
def node_key(self, args: dict[str, Any]) -> CacheKey | None:
|
|
120
|
+
"""node_key() 节点的联合键(代码体 hash + 函数所有参数拼合 hash)
|
|
121
|
+
|
|
122
|
+
参数或代码体哈希不可用时返回 None(不缓存)。
|
|
123
|
+
"""
|
|
124
|
+
if self.code_hash is None:
|
|
125
|
+
return None
|
|
126
|
+
return self._make_key(try_hash({"code": self.code_hash, "args": args}))
|
|
127
|
+
|
|
128
|
+
def _make_key(self, payload_hash: str | None) -> CacheKey | None:
|
|
129
|
+
if payload_hash is None or self.cache is None:
|
|
130
|
+
return None
|
|
131
|
+
return CacheKey(
|
|
132
|
+
key=union_key(self.session_scope, self.workflow, self.node_id,
|
|
133
|
+
payload_hash),
|
|
134
|
+
session_scope=self.session_scope,
|
|
135
|
+
workflow=self.workflow,
|
|
136
|
+
node_id=self.node_id,
|
|
137
|
+
payload_hash=payload_hash,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WorkflowCache:
|
|
142
|
+
"""WorkflowCache 基于 SQLite 的容错缓存。
|
|
143
|
+
|
|
144
|
+
任一操作失败都会把实例标记为 broken 并降级为“读未命中/写忽略”,
|
|
145
|
+
保证缓存层故障不影响工作流本身。线程安全(内部互斥锁 + WAL)。
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, db_path: str | os.PathLike[str]) -> None:
|
|
149
|
+
self.db_path = pathlib.Path(db_path)
|
|
150
|
+
self._lock = threading.Lock()
|
|
151
|
+
self._broken = False
|
|
152
|
+
self._conn: sqlite3.Connection | None = None
|
|
153
|
+
try:
|
|
154
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
connection = sqlite3.connect(str(self.db_path),
|
|
156
|
+
check_same_thread=False)
|
|
157
|
+
connection.execute("PRAGMA journal_mode=WAL")
|
|
158
|
+
connection.executescript(_SCHEMA)
|
|
159
|
+
connection.commit()
|
|
160
|
+
self._conn = connection
|
|
161
|
+
except (OSError, sqlite3.Error) as exc:
|
|
162
|
+
self._disable(exc)
|
|
163
|
+
|
|
164
|
+
# === 内部工具 ===
|
|
165
|
+
|
|
166
|
+
def _disable(self, exc: Exception) -> None:
|
|
167
|
+
"""_disable() 首次故障时提示一次并停用缓存"""
|
|
168
|
+
already_broken = self._broken
|
|
169
|
+
self._broken = True
|
|
170
|
+
self._conn = None
|
|
171
|
+
if not already_broken:
|
|
172
|
+
print(f"[dynworkflow] workflow cache disabled: {exc}",
|
|
173
|
+
file=sys.stderr)
|
|
174
|
+
|
|
175
|
+
def get_agent(self, info: CacheKey) -> tuple[str, str] | None:
|
|
176
|
+
"""get_agent() 查询 Agent 条目,返回 (kind, value);未命中返回 None"""
|
|
177
|
+
conn = self._conn
|
|
178
|
+
if conn is None:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
with self._lock:
|
|
182
|
+
row = conn.execute(
|
|
183
|
+
"SELECT kind, value FROM agent_cache WHERE key = ?",
|
|
184
|
+
(info.key,),
|
|
185
|
+
).fetchone()
|
|
186
|
+
except sqlite3.Error as exc:
|
|
187
|
+
self._disable(exc)
|
|
188
|
+
return None
|
|
189
|
+
if row is None:
|
|
190
|
+
return None
|
|
191
|
+
kind = str(row[0])
|
|
192
|
+
if kind not in {"result", "session"}:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
parsed: Any = json.loads(str(row[1]))
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
return None
|
|
198
|
+
return kind, parsed
|
|
199
|
+
|
|
200
|
+
def put_agent(self, info: CacheKey, kind: str, value: Any) -> bool:
|
|
201
|
+
"""put_agent() 写入 Agent 条目;value 不可序列化时忽略"""
|
|
202
|
+
serialized = _try_serialize(value)
|
|
203
|
+
if serialized is None or kind not in {"result", "session"}:
|
|
204
|
+
return False
|
|
205
|
+
return self._put("agent_cache", info, serialized, extra_kind=kind)
|
|
206
|
+
|
|
207
|
+
def get_node(self, info: CacheKey) -> dict[str, Any] | None:
|
|
208
|
+
"""get_node() 查询节点条目。
|
|
209
|
+
|
|
210
|
+
返回形如 ``{"kind": "none"}``、``{"kind": "result", "value": ...}``
|
|
211
|
+
或 ``{"kind": "executes", "items": [{"node_id": ..., "args": ...}]}``;
|
|
212
|
+
格式非法视为未命中。
|
|
213
|
+
"""
|
|
214
|
+
conn = self._conn
|
|
215
|
+
if conn is None:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
with self._lock:
|
|
219
|
+
row = conn.execute(
|
|
220
|
+
"SELECT value FROM node_cache WHERE key = ?",
|
|
221
|
+
(info.key,),
|
|
222
|
+
).fetchone()
|
|
223
|
+
except sqlite3.Error as exc:
|
|
224
|
+
self._disable(exc)
|
|
225
|
+
return None
|
|
226
|
+
if row is None:
|
|
227
|
+
return None
|
|
228
|
+
try:
|
|
229
|
+
parsed: Any = json.loads(str(row[0]))
|
|
230
|
+
except json.JSONDecodeError:
|
|
231
|
+
return None
|
|
232
|
+
if not isinstance(parsed, dict):
|
|
233
|
+
return None
|
|
234
|
+
kind = parsed.get("kind")
|
|
235
|
+
if kind not in {"none", "result", "executes"}:
|
|
236
|
+
return None
|
|
237
|
+
if kind == "executes":
|
|
238
|
+
items = parsed.get("items")
|
|
239
|
+
if not isinstance(items, list):
|
|
240
|
+
return None
|
|
241
|
+
for item in items:
|
|
242
|
+
if (not isinstance(item, dict)
|
|
243
|
+
or not isinstance(item.get("node_id"), str)
|
|
244
|
+
or not isinstance(item.get("args"), dict)):
|
|
245
|
+
return None
|
|
246
|
+
return parsed
|
|
247
|
+
|
|
248
|
+
def put_node(self, info: CacheKey, descriptor: dict[str, Any]) -> bool:
|
|
249
|
+
"""put_node() 写入节点条目;描述符不可整体序列化时忽略"""
|
|
250
|
+
payload = _try_serialize(descriptor)
|
|
251
|
+
if payload is None:
|
|
252
|
+
return False
|
|
253
|
+
return self._put("node_cache", info, payload, extra_kind=None)
|
|
254
|
+
|
|
255
|
+
def close(self) -> None:
|
|
256
|
+
"""close() 关闭底层连接(进程退出前非必须调用)"""
|
|
257
|
+
conn = self._conn
|
|
258
|
+
self._conn = None
|
|
259
|
+
if conn is not None:
|
|
260
|
+
try:
|
|
261
|
+
with self._lock:
|
|
262
|
+
conn.close()
|
|
263
|
+
except sqlite3.Error:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def _put(self, table: str, info: CacheKey, serialized: str,
|
|
267
|
+
*, extra_kind: str | None) -> bool:
|
|
268
|
+
"""_put() INSERT OR REPLACE 一条记录;kind 仅 agent_cache 使用"""
|
|
269
|
+
conn = self._conn
|
|
270
|
+
if conn is None:
|
|
271
|
+
return False
|
|
272
|
+
columns = ("key, value, session_scope, workflow, node_id,"
|
|
273
|
+
" payload_hash, updated_at")
|
|
274
|
+
placeholders = "?, ?, ?, ?, ?, ?, ?"
|
|
275
|
+
params: list[Any] = [
|
|
276
|
+
info.key, serialized, info.session_scope, info.workflow,
|
|
277
|
+
info.node_id, info.payload_hash, time.time(),
|
|
278
|
+
]
|
|
279
|
+
if extra_kind is not None:
|
|
280
|
+
columns += ", kind"
|
|
281
|
+
placeholders += ", ?"
|
|
282
|
+
params.append(extra_kind)
|
|
283
|
+
sql = (f"INSERT OR REPLACE INTO {table} ({columns}) "
|
|
284
|
+
f"VALUES ({placeholders})")
|
|
285
|
+
try:
|
|
286
|
+
with self._lock:
|
|
287
|
+
conn.execute(sql, params)
|
|
288
|
+
conn.commit()
|
|
289
|
+
except sqlite3.Error as exc:
|
|
290
|
+
self._disable(exc)
|
|
291
|
+
return False
|
|
292
|
+
return True
|