pi-dynamic-workflows-py 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,133 @@
1
+ """pi-dynamic-workflows — Dynamic workflow orchestration for pi-python.
2
+
3
+ Provides:
4
+ - ``workflow`` tool for multi-agent orchestration via Python scripts
5
+ - ``/workflows`` command for listing and managing runs
6
+ - 5 built-in workflow patterns: deep-research, adversarial-review,
7
+ code-review, multi-perspective, codebase-audit
8
+ - Model tier routing (small/medium/big)
9
+ - Token budget tracking
10
+
11
+ Install: ``pip install pi-dynamic-workflows-py``
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING
18
+
19
+ from pi_dynamic_workflows.builtin_workflows import (
20
+ BUILTIN_WORKFLOW_NAMES,
21
+ BUILTIN_WORKFLOWS,
22
+ )
23
+ from pi_dynamic_workflows.builtin_workflows import (
24
+ resolve_builtin_workflow as resolve_builtin_workflow,
25
+ )
26
+ from pi_dynamic_workflows.workflow_tool import create_workflow_tool
27
+
28
+ if TYPE_CHECKING:
29
+ from pi_agent_core.extensions import ExtensionAPI
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _handle_workflows_command(pi: ExtensionAPI, args: str) -> None:
35
+ """Handler for ``/workflows`` command."""
36
+ parts = args.strip().split(maxsplit=1)
37
+ sub = parts[0] if parts else "list"
38
+
39
+ if sub == "list" or not sub:
40
+ names = ", ".join(BUILTIN_WORKFLOW_NAMES)
41
+ pi.send_message(f"Available built-in workflows: {names}")
42
+ elif sub == "help":
43
+ lines = ["## Built-in Workflow Patterns\n"]
44
+ for name, desc in BUILTIN_WORKFLOWS.items():
45
+ lines.append(f"- **{name}**: {desc.description}")
46
+ lines.append("\nUse the `workflow` tool with `name` parameter to run a built-in pattern.")
47
+ pi.send_message("\n".join(lines))
48
+ else:
49
+ pi.send_message(f"Unknown subcommand: {sub}\nUsage: /workflows [list|help]")
50
+
51
+
52
+ def _register_builtin_commands(pi: ExtensionAPI) -> None:
53
+ """Register slash commands for each built-in workflow pattern.
54
+
55
+ These are passthrough commands: advertised for client autocomplete
56
+ but forwarded to the LLM so it invokes the ``workflow`` tool.
57
+ """
58
+ _noop = lambda args: None # noqa: E731
59
+ for name, desc in BUILTIN_WORKFLOWS.items():
60
+ pi.register_command(
61
+ name,
62
+ description=desc.description,
63
+ handler=_noop,
64
+ passthrough=True,
65
+ )
66
+
67
+
68
+ def _try_build_real_executor(pi: ExtensionAPI) -> tuple:
69
+ """Try to build a HarnessSubagentExecutor from the bridge. Returns (executor, manager)."""
70
+ try:
71
+ bridge = pi._require_bridge()
72
+ stream_fn = getattr(bridge, "stream_fn", None)
73
+ model = getattr(bridge, "model", None)
74
+ get_api_key = getattr(bridge, "get_api_key_fn", None)
75
+ if stream_fn is None or model is None:
76
+ return None, None
77
+
78
+ from pi_dynamic_workflows.manager import WorkflowManager
79
+ from pi_dynamic_workflows.subagent import HarnessSubagentExecutor
80
+
81
+ executor = HarnessSubagentExecutor(
82
+ stream_fn=stream_fn,
83
+ parent_model=model,
84
+ cwd=pi.cwd,
85
+ get_api_key=get_api_key,
86
+ )
87
+ manager = WorkflowManager(bridge)
88
+ return executor, manager
89
+ except Exception:
90
+ logger.debug("Could not build real executor; falling back to mock", exc_info=True)
91
+ return None, None
92
+
93
+
94
+ def _register_saved_workflows(pi: ExtensionAPI) -> None:
95
+ """Scan saved workflow directories and register slash commands."""
96
+ try:
97
+ from pi_dynamic_workflows.store import WorkflowStore
98
+
99
+ store = WorkflowStore(cwd=pi.cwd)
100
+ for wf in store.scan():
101
+ if wf.name in BUILTIN_WORKFLOW_NAMES:
102
+ continue
103
+ pi.register_command(
104
+ wf.name,
105
+ description=wf.description or f"Run saved workflow: {wf.name}",
106
+ handler=lambda args, _wf=wf: pi.send_message(
107
+ f"To run the saved workflow **{_wf.name}**, use the `workflow` tool with:\n"
108
+ f" script=<contents of {_wf.path}>\n\n"
109
+ f"Description: {_wf.description}"
110
+ ),
111
+ )
112
+ except Exception:
113
+ logger.debug("Saved workflow scan failed", exc_info=True)
114
+
115
+
116
+ def activate(pi: ExtensionAPI) -> None:
117
+ """Extension entry point — called by the ExtensionLoader."""
118
+ executor, manager = _try_build_real_executor(pi)
119
+
120
+ pi.register_tool(create_workflow_tool(executor=executor, cwd=pi.cwd, manager=manager))
121
+
122
+ pi.register_command(
123
+ "workflows",
124
+ description="List and manage dynamic workflows",
125
+ handler=lambda args: _handle_workflows_command(pi, args if isinstance(args, str) else ""),
126
+ )
127
+
128
+ _register_builtin_commands(pi)
129
+ _register_saved_workflows(pi)
130
+
131
+ if manager is not None:
132
+ bridge = pi._require_bridge()
133
+ bridge.register_cleanup(manager.shutdown)
@@ -0,0 +1,42 @@
1
+ """Token budget tracking for workflow runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class TokenBudget:
11
+ """Tracks token usage against an optional budget."""
12
+
13
+ total: int | None = None
14
+ _spent: int = field(default=0, init=False)
15
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
16
+
17
+ def spent(self) -> int:
18
+ with self._lock:
19
+ return self._spent
20
+
21
+ def remaining(self) -> int | None:
22
+ if self.total is None:
23
+ return None
24
+ with self._lock:
25
+ return max(0, self.total - self._spent)
26
+
27
+ def add(self, tokens: int) -> None:
28
+ with self._lock:
29
+ self._spent += tokens
30
+
31
+ def exceeded(self) -> bool:
32
+ if self.total is None:
33
+ return False
34
+ with self._lock:
35
+ return self._spent >= self.total
36
+
37
+ def to_dict(self) -> dict[str, int | None]:
38
+ return {
39
+ "total": self.total,
40
+ "spent": self.spent(),
41
+ "remaining": self.remaining(),
42
+ }
@@ -0,0 +1,344 @@
1
+ """Built-in workflow patterns (5 curated patterns from upstream)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ DEFAULT_MULTI_PERSPECTIVES = [
9
+ "technical",
10
+ "product",
11
+ "security",
12
+ "user experience",
13
+ "maintainability",
14
+ ]
15
+
16
+
17
+ @dataclass
18
+ class BuiltinWorkflowInvocation:
19
+ script: str
20
+ name: str
21
+
22
+
23
+ @dataclass
24
+ class BuiltinWorkflowDescriptor:
25
+ name: str
26
+ description: str
27
+ resolve: Any # (args) -> BuiltinWorkflowInvocation
28
+
29
+
30
+ def _require_str(args: dict[str, Any], key: str, pattern_name: str) -> str:
31
+ v = args.get(key)
32
+ if not isinstance(v, str) or not v.strip():
33
+ raise ValueError(
34
+ f'Built-in workflow "{pattern_name}" requires args.{key} to be a non-empty string.'
35
+ )
36
+ return v
37
+
38
+
39
+ def _require_str_list(args: dict[str, Any], key: str, pattern_name: str) -> list[str]:
40
+ v = args.get(key)
41
+ if not isinstance(v, list) or not v or not all(isinstance(s, str) and s.strip() for s in v):
42
+ raise ValueError(
43
+ f'Built-in workflow "{pattern_name}" requires args.{key} '
44
+ "to be a non-empty list of non-empty strings."
45
+ )
46
+ return v
47
+
48
+
49
+ def _as_record(args: Any) -> dict[str, Any]:
50
+ return args if isinstance(args, dict) else {}
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Pattern generators — produce Python workflow scripts
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def generate_deep_research() -> str:
59
+ return """\
60
+ meta = {"name": "deep_research", "description": "Research a question with cross-checked sources"}
61
+
62
+ async def main():
63
+ question = args["question"]
64
+ phase("Generate search angles")
65
+ angles = await agent(
66
+ f"Generate 5 diverse search angles for researching: {question}\\n"
67
+ "Return each angle on a new line, numbered 1-5.",
68
+ tier="small",
69
+ )
70
+
71
+ angle_list = [line.strip() for line in (angles or "").split("\\n") if line.strip() and line.strip()[0].isdigit()]
72
+ if not angle_list:
73
+ angle_list = [question]
74
+
75
+ phase("Research")
76
+ findings = await parallel([
77
+ lambda a=angle: agent(
78
+ f"Research this angle thoroughly: {a}\\n"
79
+ f"Original question: {question}\\n"
80
+ "Provide detailed findings with specific facts, data, and sources.",
81
+ tier="medium",
82
+ )
83
+ for angle in angle_list[:5]
84
+ ])
85
+
86
+ phase("Cross-check")
87
+ combined = "\\n\\n---\\n\\n".join(f"Angle: {a}\\nFindings: {f}" for a, f in zip(angle_list, findings) if f)
88
+ verified = await agent(
89
+ f"Cross-check these research findings for accuracy and consistency:\\n\\n{combined}\\n\\n"
90
+ "Flag any contradictions, unsupported claims, or areas needing more evidence. "
91
+ "Rate confidence for each finding.",
92
+ tier="big",
93
+ )
94
+
95
+ phase("Synthesize")
96
+ report = await agent(
97
+ f"Synthesize a comprehensive research report on: {question}\\n\\n"
98
+ f"Research findings:\\n{combined}\\n\\n"
99
+ f"Cross-check results:\\n{verified}\\n\\n"
100
+ "Write a well-structured report with an executive summary, key findings, "
101
+ "confidence levels, and areas for further research.",
102
+ tier="big",
103
+ )
104
+
105
+ result(report)
106
+ """
107
+
108
+
109
+ def generate_adversarial_review() -> str:
110
+ return """\
111
+ meta = {"name": "adversarial_review", "description": "Investigate then cross-check with skeptical reviewers"}
112
+
113
+ async def main():
114
+ task = args["task"]
115
+ reviewers = args.get("reviewers", 3)
116
+
117
+ phase("Investigate")
118
+ investigation = await agent(
119
+ f"Thoroughly investigate: {task}\\n"
120
+ "Provide detailed findings with evidence and reasoning.",
121
+ tier="big",
122
+ )
123
+
124
+ phase("Adversarial review")
125
+ reviews = await parallel([
126
+ lambda i=i: agent(
127
+ f"You are Reviewer #{i+1}. Critically review this investigation:\\n\\n{investigation}\\n\\n"
128
+ "Be skeptical. Challenge assumptions, find gaps, identify weak evidence, "
129
+ "and suggest what was missed. Rate each finding as: confirmed / questionable / refuted.",
130
+ tier="medium",
131
+ )
132
+ for i in range(reviewers)
133
+ ])
134
+
135
+ phase("Synthesize")
136
+ all_reviews = "\\n\\n---\\n\\n".join(f"Reviewer #{i+1}:\\n{r}" for i, r in enumerate(reviews) if r)
137
+ synthesis = await agent(
138
+ f"Synthesize the investigation and all reviewer feedback:\\n\\n"
139
+ f"Original investigation:\\n{investigation}\\n\\n"
140
+ f"Reviews:\\n{all_reviews}\\n\\n"
141
+ "Produce a final report with confidence-rated findings. "
142
+ "Only include findings that survived adversarial review.",
143
+ tier="big",
144
+ )
145
+ result(synthesis)
146
+ """
147
+
148
+
149
+ def generate_code_review() -> str:
150
+ return """\
151
+ meta = {"name": "code_review", "description": "Multi-angle parallel code review"}
152
+
153
+ async def main():
154
+ diff = args["diff"]
155
+ diff_source = args.get("diff_source", "unknown")
156
+
157
+ phase("Parallel review")
158
+ angles = [
159
+ ("correctness", "Find bugs, logic errors, off-by-one mistakes, race conditions, and unhandled edge cases."),
160
+ ("security", "Find security vulnerabilities: injection, auth bypass, data exposure, unsafe deserialization."),
161
+ ("performance", "Find performance issues: N+1 queries, unnecessary allocations, missing caching, O(n²) loops."),
162
+ ("simplification", "Find unnecessary complexity: dead code, over-abstraction, code that could be simplified."),
163
+ ("reuse", "Find code duplication and opportunities to use existing utilities or extract shared helpers."),
164
+ ]
165
+
166
+ findings = await parallel([
167
+ lambda name=name, instruction=instruction: agent(
168
+ f"Review this diff as a {name} specialist:\\n\\n```diff\\n{diff[:20000]}\\n```\\n\\n{instruction}\\n"
169
+ "List each finding with file, line, severity (critical/major/minor), and a fix suggestion.",
170
+ tier="medium",
171
+ )
172
+ for name, instruction in angles
173
+ ])
174
+
175
+ phase("Verify and rank")
176
+ combined = "\\n\\n---\\n\\n".join(
177
+ f"**{name}** reviewer:\\n{f}"
178
+ for (name, _), f in zip(angles, findings)
179
+ if f
180
+ )
181
+ verified = await agent(
182
+ f"You received findings from {len(angles)} code reviewers. Verify and rank them:\\n\\n{combined}\\n\\n"
183
+ "Remove duplicates and false positives. Rank remaining findings by impact. "
184
+ "Output a numbered list, most critical first, with actionable fix suggestions.",
185
+ tier="big",
186
+ )
187
+ result(verified)
188
+ """
189
+
190
+
191
+ def generate_multi_perspective(topic: str, perspectives: list[str]) -> str:
192
+ persp_list = repr(perspectives)
193
+ return f"""\
194
+ meta = {{"name": "multi_perspective", "description": "Analyze from multiple perspectives"}}
195
+
196
+ async def main():
197
+ topic = args.get("topic", {topic!r})
198
+ perspectives = args.get("perspectives", {persp_list})
199
+
200
+ phase("Parallel analysis")
201
+ analyses = await parallel([
202
+ lambda p=p: agent(
203
+ f"Analyze this topic from a {{p}} perspective:\\n\\n{{topic}}\\n\\n"
204
+ f"Focus exclusively on {{p}} concerns, trade-offs, and implications. "
205
+ "Be specific and provide actionable insights.",
206
+ tier="medium",
207
+ )
208
+ for p in perspectives
209
+ ])
210
+
211
+ phase("Synthesize")
212
+ combined = "\\n\\n---\\n\\n".join(
213
+ f"**{{p}}** perspective:\\n{{a}}"
214
+ for p, a in zip(perspectives, analyses)
215
+ if a
216
+ )
217
+ synthesis = await agent(
218
+ f"Synthesize these multi-perspective analyses into a unified assessment:\\n\\n{{combined}}\\n\\n"
219
+ "Identify consensus, tensions, and trade-offs. "
220
+ "Provide a balanced recommendation that accounts for all perspectives.",
221
+ tier="big",
222
+ )
223
+ result(synthesis)
224
+ """
225
+
226
+
227
+ def generate_codebase_audit(scope: str, checks: list[str]) -> str:
228
+ return f"""\
229
+ meta = {{"name": "codebase_audit", "description": "Parallel codebase audit"}}
230
+
231
+ async def main():
232
+ scope = args.get("scope", {scope!r})
233
+ checks = args.get("checks", {checks!r})
234
+
235
+ phase("Parallel checks")
236
+ findings = await parallel([
237
+ lambda check=check: agent(
238
+ f"Audit the codebase (scope: {{scope}}) for: {{check}}\\n\\n"
239
+ "Examine the code thoroughly. List each finding with file path, "
240
+ "line numbers, severity, and a recommended fix.",
241
+ tier="medium",
242
+ )
243
+ for check in checks
244
+ ])
245
+
246
+ phase("Cross-validate")
247
+ combined = "\\n\\n---\\n\\n".join(
248
+ f"**{{check}}**:\\n{{f}}"
249
+ for check, f in zip(checks, findings)
250
+ if f
251
+ )
252
+ validated = await agent(
253
+ f"Cross-validate and compile these audit findings:\\n\\n{{combined}}\\n\\n"
254
+ "Remove false positives, merge overlapping findings, and produce "
255
+ "a prioritized report with actionable recommendations.",
256
+ tier="big",
257
+ )
258
+ result(validated)
259
+ """
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Registry
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def _resolve_multi_perspective(args: Any) -> BuiltinWorkflowInvocation:
268
+ a = _as_record(args)
269
+ return BuiltinWorkflowInvocation(
270
+ script=generate_multi_perspective(
271
+ _require_str(a, "topic", "multi-perspective"),
272
+ a.get("perspectives", DEFAULT_MULTI_PERSPECTIVES),
273
+ ),
274
+ name="multi-perspective",
275
+ )
276
+
277
+
278
+ def _resolve_codebase_audit(args: Any) -> BuiltinWorkflowInvocation:
279
+ a = _as_record(args)
280
+ return BuiltinWorkflowInvocation(
281
+ script=generate_codebase_audit(
282
+ _require_str(a, "scope", "codebase-audit"),
283
+ _require_str_list(a, "checks", "codebase-audit"),
284
+ ),
285
+ name="codebase-audit",
286
+ )
287
+
288
+
289
+ BUILTIN_WORKFLOWS: dict[str, BuiltinWorkflowDescriptor] = {
290
+ "deep-research": BuiltinWorkflowDescriptor(
291
+ name="deep-research",
292
+ description=(
293
+ "Research a question across the web with "
294
+ "cross-checked sources. args: { question: str }."
295
+ ),
296
+ resolve=lambda args: BuiltinWorkflowInvocation(
297
+ script=generate_deep_research(),
298
+ name="deep-research",
299
+ ),
300
+ ),
301
+ "adversarial-review": BuiltinWorkflowDescriptor(
302
+ name="adversarial-review",
303
+ description=(
304
+ "Investigate then cross-check with skeptical reviewers. "
305
+ "args: { task: str, reviewers?: int }."
306
+ ),
307
+ resolve=lambda args: BuiltinWorkflowInvocation(
308
+ script=generate_adversarial_review(),
309
+ name="adversarial-review",
310
+ ),
311
+ ),
312
+ "code-review": BuiltinWorkflowDescriptor(
313
+ name="code-review",
314
+ description="Multi-angle parallel code review. args: { diff: str }.",
315
+ resolve=lambda args: BuiltinWorkflowInvocation(
316
+ script=generate_code_review(),
317
+ name="code-review",
318
+ ),
319
+ ),
320
+ "multi-perspective": BuiltinWorkflowDescriptor(
321
+ name="multi-perspective",
322
+ description=(
323
+ "Analyze a topic from several perspectives, then synthesize. "
324
+ "args: { topic: str, perspectives?: list[str] }."
325
+ ),
326
+ resolve=_resolve_multi_perspective,
327
+ ),
328
+ "codebase-audit": BuiltinWorkflowDescriptor(
329
+ name="codebase-audit",
330
+ description=(
331
+ "Run parallel checks against a codebase scope. args: { scope: str, checks: list[str] }."
332
+ ),
333
+ resolve=_resolve_codebase_audit,
334
+ ),
335
+ }
336
+
337
+ BUILTIN_WORKFLOW_NAMES = list(BUILTIN_WORKFLOWS.keys())
338
+
339
+
340
+ def resolve_builtin_workflow(name: str, args: Any = None) -> BuiltinWorkflowInvocation | None:
341
+ desc = BUILTIN_WORKFLOWS.get(name)
342
+ if desc is None:
343
+ return None
344
+ return desc.resolve(args)
@@ -0,0 +1,154 @@
1
+ """Append-only JSONL journal for workflow run replay.
2
+
3
+ Records each host call (``agent()``, ``phase()``, ``log()``) with a SHA-256
4
+ request hash so that re-running the same workflow can skip LLM calls whose
5
+ inputs have not changed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import time
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ MAX_ENTRIES = 10_000
21
+ MAX_FILE_BYTES = 64 * 1024 * 1024 # 64 MB
22
+
23
+ _MISS = object()
24
+
25
+
26
+ def _deterministic_json(obj: Any) -> str:
27
+ """JSON-serialise *obj* with sorted keys for stable hashing."""
28
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False, default=str)
29
+
30
+
31
+ def hash_request(kind: str, params: Any) -> str:
32
+ """SHA-256 hex digest of ``kind`` + deterministic JSON of *params*."""
33
+ payload = _deterministic_json({"kind": kind, "params": params})
34
+ return hashlib.sha256(payload.encode()).hexdigest()
35
+
36
+
37
+ @dataclass
38
+ class JournalEntry:
39
+ seq: int
40
+ kind: str
41
+ req_hash: str
42
+ result: Any
43
+ at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
44
+
45
+
46
+ class Journal:
47
+ """Append-only JSONL journal for deterministic replay.
48
+
49
+ * ``try_replay(kind, req_hash)`` — returns cached result on hash match,
50
+ or ``_MISS`` on divergence (auto-truncates).
51
+ * ``append(kind, req_hash, result)`` — records a new entry.
52
+ """
53
+
54
+ def __init__(self, path: Path | None = None) -> None:
55
+ self._entries: list[JournalEntry] = []
56
+ self._path = path
57
+ self._cursor = 0
58
+
59
+ # -- persistence ---------------------------------------------------------
60
+
61
+ @classmethod
62
+ def load(cls, path: Path) -> Journal:
63
+ """Load an existing journal file (one JSON object per line)."""
64
+ j = cls(path)
65
+ if not path.exists():
66
+ return j
67
+ try:
68
+ with open(path, encoding="utf-8") as fh:
69
+ for line in fh:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ raw = json.loads(line)
74
+ j._entries.append(
75
+ JournalEntry(
76
+ seq=raw["seq"],
77
+ kind=raw["kind"],
78
+ req_hash=raw["req_hash"],
79
+ result=raw.get("result"),
80
+ at_ms=raw.get("at_ms", 0),
81
+ )
82
+ )
83
+ except (json.JSONDecodeError, KeyError, OSError) as exc:
84
+ logger.warning("Journal load failed (%s); starting fresh", exc)
85
+ j._entries.clear()
86
+ return j
87
+
88
+ def _flush_to_disk(self) -> None:
89
+ if self._path is None:
90
+ return
91
+ self._path.parent.mkdir(parents=True, exist_ok=True)
92
+ with open(self._path, "w", encoding="utf-8") as fh:
93
+ for entry in self._entries:
94
+ fh.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
95
+
96
+ # -- replay / record -----------------------------------------------------
97
+
98
+ def try_replay(self, kind: str, req_hash: str) -> Any:
99
+ """Return cached result if the next entry matches, else ``_MISS``.
100
+
101
+ On hash mismatch the journal is truncated from the current cursor
102
+ position so subsequent calls will record fresh entries.
103
+ """
104
+ if self._cursor >= len(self._entries):
105
+ return _MISS
106
+ entry = self._entries[self._cursor]
107
+ if entry.kind == kind and entry.req_hash == req_hash:
108
+ self._cursor += 1
109
+ return entry.result
110
+ self.truncate_from(self._cursor)
111
+ return _MISS
112
+
113
+ def append(self, kind: str, req_hash: str, result: Any) -> None:
114
+ """Record a new host-call result."""
115
+ if len(self._entries) >= MAX_ENTRIES:
116
+ logger.warning("Journal entry cap (%d) reached; skipping append", MAX_ENTRIES)
117
+ return
118
+ entry = JournalEntry(
119
+ seq=len(self._entries),
120
+ kind=kind,
121
+ req_hash=req_hash,
122
+ result=result,
123
+ )
124
+ line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
125
+ if self._path is not None:
126
+ self._path.parent.mkdir(parents=True, exist_ok=True)
127
+ current_size = self._path.stat().st_size if self._path.exists() else 0
128
+ if current_size + len(line.encode("utf-8")) > MAX_FILE_BYTES:
129
+ logger.warning(
130
+ "Journal file size limit (%d bytes) reached; skipping append",
131
+ MAX_FILE_BYTES,
132
+ )
133
+ return
134
+ with open(self._path, "a", encoding="utf-8") as fh:
135
+ fh.write(line)
136
+ self._entries.append(entry)
137
+ self._cursor = len(self._entries)
138
+
139
+ def truncate_from(self, seq: int) -> None:
140
+ """Discard entries from *seq* onwards (divergence)."""
141
+ if seq < len(self._entries):
142
+ self._entries = self._entries[:seq]
143
+ self._cursor = seq
144
+ self._flush_to_disk()
145
+
146
+ # -- introspection -------------------------------------------------------
147
+
148
+ @property
149
+ def entry_count(self) -> int:
150
+ return len(self._entries)
151
+
152
+ @property
153
+ def replayed_count(self) -> int:
154
+ return self._cursor