specmodule 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.
@@ -0,0 +1,216 @@
1
+ # module_harness/query.py
2
+ """共享查询层:review 历史时间线组合(roadmap Phase 0)。
3
+
4
+ CLI(host + 查询形态)、MCP、Web 三形态共同消费本模块——形态只 import,
5
+ 绝不重实现。数据源:run.sqlite 的 firings 表;容错哲学同 query_run_status
6
+ (DB 读失败返回 None,监控方绝不被 DB 锁搞崩)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class ReviewEntry:
21
+ """单个节点 firing 的审阅记录。"""
22
+
23
+ tick: int
24
+ node: str
25
+ status: str # ok | failed | aborted
26
+ output: Any = None
27
+ error: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class ReviewTimeline:
32
+ """完整审阅时间线:firings 顺序,同 (tick, node) 去重 keep-first。"""
33
+
34
+ module_id: str
35
+ entries: list[ReviewEntry] = field(default_factory=list)
36
+ latest_tick: int | None = None
37
+
38
+
39
+ def _run_db_path(module_id: str, base_dir: Path | None) -> Path:
40
+ """``<base>/.specmodule/runs/<module_id>/run.sqlite``(与 Module 对齐)。"""
41
+ base = base_dir if base_dir is not None else Path.cwd()
42
+ return base / ".specmodule" / "runs" / module_id / "run.sqlite"
43
+
44
+
45
+ def build_timeline(module_id: str, base_dir: Path | None = None) -> ReviewTimeline | None:
46
+ """从 run.sqlite 构建审阅时间线。无 DB / 读失败 → None。"""
47
+ db_path = _run_db_path(module_id, base_dir)
48
+ if not db_path.exists():
49
+ return None
50
+ try:
51
+ from tickflow.persistence import SqliteBackend
52
+
53
+ backend = SqliteBackend(db_path)
54
+ try:
55
+ rows = backend.list_firings(module_id)
56
+ finally:
57
+ backend.close()
58
+ except Exception:
59
+ log.exception("读取 run.sqlite 失败(返回 None): %s", db_path)
60
+ return None
61
+
62
+ entries: list[ReviewEntry] = []
63
+ seen: set[tuple[int, str]] = set()
64
+ latest: int | None = None
65
+ for d in rows:
66
+ node = d.get("node")
67
+ if not node:
68
+ continue # 空 node 行跳过(损坏/异常行不阻断时间线)
69
+ try:
70
+ tick = int(d.get("tick", 0))
71
+ except (TypeError, ValueError):
72
+ log.warning("跳过损坏的 firing 行(tick 非法): %r", d)
73
+ continue
74
+ if (tick, node) in seen:
75
+ continue # 与 tickflow audit() 去重语义一致(restore 重放兼容)
76
+ seen.add((tick, node))
77
+ entries.append(
78
+ ReviewEntry(
79
+ tick=tick,
80
+ node=node,
81
+ status=str(d.get("status", "ok")),
82
+ output=d.get("output"),
83
+ error=d.get("error"),
84
+ )
85
+ )
86
+ latest = tick if latest is None else max(latest, tick)
87
+ return ReviewTimeline(module_id=module_id, entries=entries, latest_tick=latest)
88
+
89
+
90
+ def _latest_of(entries: list[ReviewEntry]) -> int | None:
91
+ """过滤后子集中最新 tick(无条目 → None)。"""
92
+ return max((e.tick for e in entries), default=None)
93
+
94
+
95
+ def filter_failed(timeline: ReviewTimeline) -> ReviewTimeline:
96
+ """只看失败/中止节点(定位问题 tick 的核心路径)。
97
+
98
+ latest_tick 重算为过滤子集中的最新 tick(避免过滤后仍报全局 tick 误导)。
99
+ """
100
+ entries = [e for e in timeline.entries if e.status != "ok"]
101
+ return ReviewTimeline(
102
+ module_id=timeline.module_id,
103
+ entries=entries,
104
+ latest_tick=_latest_of(entries),
105
+ )
106
+
107
+
108
+ def filter_tick(timeline: ReviewTimeline, tick: int) -> ReviewTimeline:
109
+ """只看指定 tick。"""
110
+ entries = [e for e in timeline.entries if e.tick == tick]
111
+ return ReviewTimeline(
112
+ module_id=timeline.module_id,
113
+ entries=entries,
114
+ latest_tick=_latest_of(entries),
115
+ )
116
+
117
+
118
+ def filter_node(timeline: ReviewTimeline, node: str) -> ReviewTimeline:
119
+ """只看指定节点的全部 firing(含 loop 多轮)。"""
120
+ entries = [e for e in timeline.entries if e.node == node]
121
+ return ReviewTimeline(
122
+ module_id=timeline.module_id,
123
+ entries=entries,
124
+ latest_tick=_latest_of(entries),
125
+ )
126
+
127
+
128
+ @dataclass
129
+ class CheckpointEntry:
130
+ """单个回退点(tick 快照或 manual 检查点)。"""
131
+
132
+ target: str # resume/rollback 直传的目标("3" 或 "manual:xxx")
133
+ tick: int
134
+ kind: str # "tick"(snapshots 表每 tick 快照)| "manual"(checkpoint() 命名点)
135
+ fired: list[str] = field(default_factory=list) # tick 快照本 tick fire 的节点;manual 为空
136
+ label: str | None = None # manual label;tick 快照为 None
137
+
138
+
139
+ @dataclass
140
+ class CheckpointList:
141
+ """全部回退点,按 tick 升序(同 tick 的 manual 排 tick 快照后)。"""
142
+
143
+ module_id: str
144
+ entries: list[CheckpointEntry] = field(default_factory=list)
145
+
146
+
147
+ def build_checkpoints(module_id: str, base_dir: Path | None = None) -> CheckpointList | None:
148
+ """列出可用回退点(snapshots 表 tick 快照 + checkpoints 表 manual 检查点)。
149
+
150
+ 数据源:run.sqlite;无 DB / 读失败 → None(容错哲学同 build_timeline——
151
+ 监控方绝不被 DB 锁搞崩)。`resume <target>` / `rollback <target>` 的
152
+ target 即条目 ``target`` 字段。
153
+ """
154
+ db_path = _run_db_path(module_id, base_dir)
155
+ if not db_path.exists():
156
+ return None
157
+ try:
158
+ from tickflow.persistence import SqliteBackend
159
+
160
+ backend = SqliteBackend(db_path)
161
+ try:
162
+ entries: list[CheckpointEntry] = []
163
+ for tick in backend.list_snapshots(module_id):
164
+ snap = backend.load_snapshot(module_id, tick)
165
+ fired = list(snap.get("fired", [])) if snap else []
166
+ entries.append(
167
+ CheckpointEntry(
168
+ target=str(tick), tick=tick, kind="tick", fired=fired
169
+ )
170
+ )
171
+ entries.extend(
172
+ CheckpointEntry(target=label, tick=tick, kind="manual", label=label)
173
+ for label, tick in backend.list_checkpoints(module_id)
174
+ )
175
+ finally:
176
+ backend.close()
177
+ except Exception:
178
+ log.exception("读取 run.sqlite 失败(返回 None): %s", db_path)
179
+ return None
180
+ entries.sort(key=lambda e: (e.tick, 0 if e.kind == "tick" else 1))
181
+ return CheckpointList(module_id=module_id, entries=entries)
182
+
183
+
184
+ def checkpoints_to_dict(cl: CheckpointList) -> dict[str, Any]:
185
+ """JSON 出口(MCP/Web 直接消费同一函数)。"""
186
+ return {
187
+ "module_id": cl.module_id,
188
+ "checkpoints": [
189
+ {
190
+ "target": e.target,
191
+ "tick": e.tick,
192
+ "kind": e.kind,
193
+ "fired": list(e.fired),
194
+ "label": e.label,
195
+ }
196
+ for e in cl.entries
197
+ ],
198
+ }
199
+
200
+
201
+ def timeline_to_dict(timeline: ReviewTimeline) -> dict[str, Any]:
202
+ """JSON 出口(MCP/Web 直接消费同一函数)。"""
203
+ return {
204
+ "module_id": timeline.module_id,
205
+ "latest_tick": timeline.latest_tick,
206
+ "entries": [
207
+ {
208
+ "tick": e.tick,
209
+ "node": e.node,
210
+ "status": e.status,
211
+ "output": e.output,
212
+ "error": e.error,
213
+ }
214
+ for e in timeline.entries
215
+ ],
216
+ }
@@ -0,0 +1,180 @@
1
+ # module_harness/registry.py
2
+ """HarnessRegistry — tickflow Registry 子类,管理 harness/script 注册。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import functools
7
+ import inspect
8
+ import time
9
+ from typing import Any, Callable
10
+
11
+ from tickflow import Registry
12
+ from tickflow.views import DictView
13
+
14
+ from .config import HarnessConfig
15
+ from .command import Command, CommandConfig
16
+ from .harness import Harness
17
+ from .events import (
18
+ EventBus,
19
+ ScriptStarted,
20
+ ScriptCompleted,
21
+ ScriptFailed,
22
+ )
23
+
24
+
25
+ class HarnessRegistry(Registry):
26
+ """tickflow Registry 子类。tickflow 零修改。
27
+
28
+ Runner 只调 ``get_body()``,不感知 harness/script/body 的区别。
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ llm_client: Any,
35
+ event_bus: EventBus | None = None,
36
+ ) -> None:
37
+ super().__init__()
38
+ self._llm_client = llm_client
39
+ self._event_bus = event_bus or EventBus.null()
40
+ self._harness_cfgs: dict[str, HarnessConfig] = {}
41
+ self._script_names: set[str] = set()
42
+ self._command_cfgs: dict[str, CommandConfig] = {}
43
+
44
+ # ── harness 注册 ──────────────────────────────────────────────
45
+
46
+ def harness(
47
+ self,
48
+ name: str,
49
+ config: HarnessConfig,
50
+ *,
51
+ promptmode: str | None = None,
52
+ prompt_extra: str | None = None,
53
+ spec_inputs: dict[str, Any] | None = None,
54
+ input_aliases: dict[str, str] | None = None,
55
+ ) -> "HarnessRegistry":
56
+ """注册一个 harness body。
57
+
58
+ ``name`` 是 graph 中 ``node.body`` 引用的名称。
59
+ ``spec_inputs``:spec 字段常量,渲染时作为占位符兜底值。
60
+ ``input_aliases``:跨节点输入别名 {field_name: producer},
61
+ prompt 的 {field} 占位符在运行时解析 producer 的输出值。
62
+ 返回 self,支持链式调用。
63
+ """
64
+ h = Harness(config, self._llm_client, self._event_bus)
65
+ body = h.build_body(
66
+ promptmode=promptmode,
67
+ prompt_extra=prompt_extra,
68
+ spec_inputs=spec_inputs,
69
+ input_aliases=input_aliases,
70
+ )
71
+ self.body(name, body)
72
+ self._harness_cfgs[name] = config
73
+ return self
74
+
75
+ # ── script 注册 ───────────────────────────────────────────────
76
+
77
+ def script(self, name: str):
78
+ """装饰器:``@reg.script('name')`` — 包裹事件发射后注册为 body。
79
+
80
+ body 执行时自动发射 ScriptStarted / ScriptCompleted / ScriptFailed。
81
+ 支持 sync 和 async 用户函数。
82
+ """
83
+ bus = self._event_bus
84
+
85
+ def deco(fn: Callable) -> Callable:
86
+ is_async = inspect.iscoroutinefunction(fn)
87
+
88
+ if is_async:
89
+ @functools.wraps(fn)
90
+ async def wrapped(view: DictView) -> Any:
91
+ node = view.node
92
+ bus.emit(ScriptStarted(
93
+ timestamp=time.monotonic(), node=node, tick=0,
94
+ ))
95
+ try:
96
+ result = await fn(view)
97
+ except Exception as e:
98
+ bus.emit(ScriptFailed(
99
+ timestamp=time.monotonic(), node=node, tick=0,
100
+ error=str(e),
101
+ ))
102
+ raise
103
+ bus.emit(ScriptCompleted(
104
+ timestamp=time.monotonic(), node=node, tick=0,
105
+ output_type=type(result).__name__,
106
+ ))
107
+ return result
108
+ else:
109
+ @functools.wraps(fn)
110
+ def wrapped(view: DictView) -> Any:
111
+ node = view.node
112
+ bus.emit(ScriptStarted(
113
+ timestamp=time.monotonic(), node=node, tick=0,
114
+ ))
115
+ try:
116
+ result = fn(view)
117
+ except Exception as e:
118
+ bus.emit(ScriptFailed(
119
+ timestamp=time.monotonic(), node=node, tick=0,
120
+ error=str(e),
121
+ ))
122
+ raise
123
+ bus.emit(ScriptCompleted(
124
+ timestamp=time.monotonic(), node=node, tick=0,
125
+ output_type=type(result).__name__,
126
+ ))
127
+ return result
128
+
129
+ self.body(name, wrapped)
130
+ self._script_names.add(name)
131
+ return wrapped
132
+
133
+ return deco
134
+
135
+ # ── command 注册 ───────────────────────────────────────────────
136
+
137
+ def command(
138
+ self,
139
+ name: str,
140
+ config: CommandConfig,
141
+ *,
142
+ timeout: float | None = None,
143
+ cwd: str | None = None,
144
+ ) -> "HarnessRegistry":
145
+ """注册一个 command body。
146
+
147
+ ``name`` 是 graph 中 ``node.body`` 引用的名称。
148
+ 返回 self,支持链式调用。
149
+ """
150
+ cmd = Command(config, self._event_bus)
151
+ body = cmd.build_body(timeout=timeout, cwd=cwd)
152
+ self.body(name, body)
153
+ self._command_cfgs[name] = config
154
+ return self
155
+
156
+ # ── 查询 ──────────────────────────────────────────────────────
157
+
158
+ def is_harness(self, name: str) -> bool:
159
+ """name 是否通过 harness() 注册。"""
160
+ return name in self._harness_cfgs
161
+
162
+ def is_script(self, name: str) -> bool:
163
+ """name 是否通过 script() 注册。"""
164
+ return name in self._script_names
165
+
166
+ def is_command(self, name: str) -> bool:
167
+ """name 是否通过 command() 注册。"""
168
+ return name in self._command_cfgs
169
+
170
+ def harness_config(self, name: str) -> HarnessConfig | None:
171
+ """返回 harness 的配置,若不是 harness 返回 None。"""
172
+ return self._harness_cfgs.get(name)
173
+
174
+ def command_config(self, name: str) -> CommandConfig | None:
175
+ """返回 command 的配置,若不是 command 返回 None。"""
176
+ return self._command_cfgs.get(name)
177
+
178
+ def guard_names(self) -> list[str]:
179
+ """已注册 guard 名列表(publish 单文件转化枚举用)。"""
180
+ return list(getattr(self, "_guards", {}).keys())