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/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