whiletrue 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.
Files changed (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ #: Every started/output/cost/error/approval/takeover is an event; the log is append-only.
9
+ EventType = Literal[
10
+ "run.started",
11
+ "run.finished",
12
+ "run.hashes",
13
+ "item.state",
14
+ "step.started",
15
+ "step.finished",
16
+ "step.error",
17
+ "step.cached",
18
+ "step.journaled",
19
+ "workspace.created",
20
+ "workspace.released",
21
+ "park.created",
22
+ "park.decided",
23
+ "comment.added",
24
+ "comment.answered",
25
+ "step.replayed",
26
+ "agent.event",
27
+ "log",
28
+ ]
29
+
30
+
31
+ @dataclass
32
+ class WtEvent:
33
+ type: EventType
34
+ run_id: str
35
+ #: Epoch milliseconds.
36
+ ts: int
37
+ data: dict[str, Any] = field(default_factory=lambda: {})
38
+ item_id: str | None = None
39
+ step_id: int | None = None
40
+
41
+
42
+ EventListener = Callable[[WtEvent], None]
43
+
44
+
45
+ def _wall_clock_ms() -> int:
46
+ return int(time.time() * 1000)
47
+
48
+
49
+ class EventBus:
50
+ def __init__(
51
+ self,
52
+ *,
53
+ now: Callable[[], int] = _wall_clock_ms,
54
+ on_error: Callable[[Exception, WtEvent], None] | None = None,
55
+ ) -> None:
56
+ self._listeners: list[EventListener] = []
57
+ self._now = now
58
+ self._on_error = on_error
59
+
60
+ def subscribe(self, listener: EventListener) -> Callable[[], None]:
61
+ self._listeners.append(listener)
62
+
63
+ def unsubscribe() -> None:
64
+ if listener in self._listeners:
65
+ self._listeners.remove(listener)
66
+
67
+ return unsubscribe
68
+
69
+ def emit(
70
+ self,
71
+ type: EventType,
72
+ run_id: str,
73
+ data: dict[str, Any] | None = None,
74
+ *,
75
+ item_id: str | None = None,
76
+ step_id: int | None = None,
77
+ ts: int | None = None,
78
+ ) -> WtEvent:
79
+ """Delivers synchronously, in subscription order. A failing listener never breaks us."""
80
+ event = WtEvent(
81
+ type=type,
82
+ run_id=run_id,
83
+ ts=self._now() if ts is None else ts,
84
+ data=data if data is not None else {},
85
+ item_id=item_id,
86
+ step_id=step_id,
87
+ )
88
+ for listener in list(self._listeners):
89
+ try:
90
+ listener(event)
91
+ except Exception as error:
92
+ if self._on_error is not None:
93
+ self._on_error(error, event)
94
+ return event
whiletrue/core/expr.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, cast
6
+
7
+ import cel
8
+ from cel import stdlib as cel_stdlib
9
+
10
+ #: Guards against pathological inputs; the native evaluator cannot be interrupted once started.
11
+ MAX_EXPR_LENGTH = 4096
12
+
13
+ #: CEL's standard, side-effect-free functions and operators. Anything else is rejected by `check`.
14
+ ALLOWED_FUNCTIONS = frozenset(cel_stdlib.STDLIB_FUNCTIONS) | frozenset(
15
+ {
16
+ "size", "has", "int", "uint", "double", "string", "bool", "bytes", "type", "dyn",
17
+ "contains", "startsWith", "endsWith", "matches", "exists", "all", "exists_one", "map",
18
+ "filter", "min", "max", "duration", "timestamp", "getFullYear", "getMonth", "getDate",
19
+ "getDayOfMonth", "getDayOfWeek", "getHours", "getMinutes", "getSeconds",
20
+ "getMilliseconds", "optional", "in",
21
+ }
22
+ ) # fmt: skip
23
+
24
+
25
+ class ExprError(ValueError):
26
+ """Raised for syntax errors and evaluation failures."""
27
+
28
+
29
+ def _is_operator(name: str) -> bool:
30
+ return name.startswith("_") or name.startswith("@")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Expr:
35
+ """A parsed CEL expression. `check` is static, `eval` runs against a context mapping."""
36
+
37
+ source: str
38
+ _program: Any
39
+
40
+ def _refs(self) -> tuple[list[str], list[str]]:
41
+ refs = cast(dict[str, list[str]], self._program.references())
42
+ return refs.get("variables", []), refs.get("functions", [])
43
+
44
+ def check(self, known_variables: set[str] | frozenset[str]) -> list[str]:
45
+ """Returns problems found without evaluating: unknown variables, disallowed functions."""
46
+ variables, functions = self._refs()
47
+ problems: list[str] = []
48
+ known = ", ".join(sorted(known_variables)) or "none"
49
+ for name in dict.fromkeys(variables):
50
+ if name not in known_variables:
51
+ problems.append(f'Unknown variable "{name}" (known: {known})')
52
+ for name in dict.fromkeys(functions):
53
+ if not _is_operator(name) and name not in ALLOWED_FUNCTIONS:
54
+ problems.append(f'Function "{name}" is not allowed')
55
+ return problems
56
+
57
+ def eval(self, context: Mapping[str, Any]) -> Any:
58
+ try:
59
+ ctx = cel.Context(dict(context))
60
+ cel_stdlib.add_stdlib_to_context(ctx) # max, split, lowerAscii, ... (all pure)
61
+ return self._program.execute(ctx)
62
+ except Exception as error:
63
+ raise ExprError(f"Cannot evaluate `{self.source}`: {error}") from error
64
+
65
+ def eval_bool(self, context: Mapping[str, Any]) -> bool:
66
+ value = self.eval(context)
67
+ if not isinstance(value, bool):
68
+ raise ExprError(f"`{self.source}` must be a boolean, got {type(value).__name__}")
69
+ return value
70
+
71
+
72
+ def parse_expr(source: str) -> Expr:
73
+ if len(source) > MAX_EXPR_LENGTH:
74
+ raise ExprError(f"Expression is too long ({len(source)} > {MAX_EXPR_LENGTH} characters)")
75
+ try:
76
+ program = cel.compile(source)
77
+ except Exception as error:
78
+ text = str(error)
79
+ if "reserve" in text.lower():
80
+ raise ExprError(
81
+ f"`{source}` uses a CEL reserved word (such as `loop`, `if`, `for`) as a name; "
82
+ "use `iter` for the loop scope"
83
+ ) from error
84
+ raise ExprError(f"Invalid expression (syntax) `{source}`: {text}") from error
85
+ return Expr(source, program)
@@ -0,0 +1,83 @@
1
+ """Records kept across runs: human input (parks, comments, steers), side effects and learnings.
2
+
3
+ Unlike runs and steps, these are keyed by *item*, not run: a gate parked in one process is decided
4
+ from another, and a comment made today is answered by tomorrow's run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal
11
+
12
+ Decision = Literal["approved", "rejected"]
13
+ LearningStatus = Literal["proposed", "approved", "rejected"]
14
+
15
+
16
+ @dataclass
17
+ class ParkRecord:
18
+ id: int
19
+ #: The run lineage (the first run of a chain of resumes) that created it.
20
+ run_id: str
21
+ item_id: str
22
+ #: The gate step that parked the item.
23
+ node_path: str
24
+ #: What is being decided (`pr`, `merge`, ...), shown in status.
25
+ what: str
26
+ #: The wake condition this park waits on.
27
+ wake: dict[str, Any]
28
+ reason: str
29
+ created_at: int
30
+ decision: Decision | None = None
31
+ notes: str | None = None
32
+ decided_by: str | None = None
33
+ decided_at: int | None = None
34
+
35
+
36
+ @dataclass
37
+ class CommentRecord:
38
+ id: int
39
+ item_id: str
40
+ #: Where it came from: `github_pr`, `local`, `ai_review`, ...
41
+ source: str
42
+ #: The id in that system, unique per (item, source).
43
+ external_id: str
44
+ author: str
45
+ body: str
46
+ created_at: int
47
+ thread: str | None = None
48
+ url: str | None = None
49
+ #: `human` comments get answered but never resolved by the agent; `reviewer` is automated.
50
+ kind: Literal["human", "reviewer"] = "human"
51
+ answered: bool = False
52
+ reply: str | None = None
53
+
54
+
55
+ @dataclass
56
+ class SteerRecord:
57
+ id: int
58
+ run_id: str
59
+ item_id: str
60
+ step: str
61
+ prompt: str
62
+ created_at: int
63
+
64
+
65
+ @dataclass
66
+ class EffectRecord:
67
+ #: A stable key naming the effect (`github.open_pr:owner/repo:wt/12`), not the run.
68
+ key: str
69
+ status: Literal["started", "done"]
70
+ run_id: str
71
+ created_at: int
72
+ ref: Any = None
73
+
74
+
75
+ @dataclass
76
+ class LearningRecord:
77
+ id: int
78
+ scope: str
79
+ note: str
80
+ status: LearningStatus
81
+ created_at: int
82
+ run_id: str | None = None
83
+ provenance: dict[str, Any] = field(default_factory=lambda: {})
whiletrue/core/lock.py ADDED
@@ -0,0 +1,86 @@
1
+ """`whiletrue.lock` (plugin version pins) and content hashes of the files a run depends on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from whiletrue.core.config import PipelineConfig, PipelineNode
11
+
12
+ LOCK_FILE = "whiletrue.lock"
13
+ LOCK_VERSION = 1
14
+
15
+
16
+ class LockError(Exception):
17
+ pass
18
+
19
+
20
+ def write_lock(path: Path, plugins: dict[str, str]) -> None:
21
+ body = {"version": LOCK_VERSION, "plugins": dict(sorted(plugins.items()))}
22
+ path.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
23
+
24
+
25
+ def read_lock(path: Path) -> dict[str, str] | None:
26
+ """The pinned plugin versions, or None when there is no lock file."""
27
+ if not path.is_file():
28
+ return None
29
+ try:
30
+ body: Any = json.loads(path.read_text(encoding="utf-8"))
31
+ plugins = body["plugins"]
32
+ if body["version"] != LOCK_VERSION or not all(
33
+ isinstance(k, str) and isinstance(v, str) for k, v in plugins.items()
34
+ ):
35
+ raise ValueError("unsupported format")
36
+ except (ValueError, KeyError, TypeError, AttributeError) as error:
37
+ raise LockError(f"Cannot read {path.name}: {error}") from error
38
+ return dict(plugins)
39
+
40
+
41
+ def check_lock(pinned: dict[str, str], actual: dict[str, str]) -> list[str]:
42
+ """Problems between the lock and the plugins in use; empty means they agree."""
43
+ problems: list[str] = []
44
+ for name, version in sorted(actual.items()):
45
+ if name not in pinned:
46
+ problems.append(f'Plugin "{name}" ({version}) is not in {LOCK_FILE}')
47
+ elif pinned[name] != version:
48
+ problems.append(f'Plugin "{name}" is {version} but {LOCK_FILE} pins {pinned[name]}')
49
+ problems += [
50
+ f'{LOCK_FILE} pins "{name}" which is no longer used'
51
+ for name in sorted(set(pinned) - set(actual))
52
+ ]
53
+ return problems
54
+
55
+
56
+ def sha256_file(path: Path) -> str:
57
+ return hashlib.sha256(path.read_bytes()).hexdigest()
58
+
59
+
60
+ def _uses_paths(node: PipelineNode) -> list[str]:
61
+ found: list[str] = []
62
+ uses = node.config.get("uses")
63
+ if isinstance(uses, str) and not uses.startswith("recipe:"):
64
+ found.append(uses)
65
+ for child in node.children:
66
+ found += _uses_paths(child)
67
+ return found
68
+
69
+
70
+ def collect_file_hashes(config: PipelineConfig, cwd: str) -> dict[str, str]:
71
+ """Hashes of the pipeline file, expanded composites and any file a step `uses:` (prompts)."""
72
+ base = Path(cwd)
73
+ candidates = [Path(config.file), *map(Path, config.sources)]
74
+ candidates += [base / p for p in _uses_paths(config.root)]
75
+ hashes: dict[str, str] = {}
76
+ for candidate in candidates:
77
+ path = candidate if candidate.is_absolute() else base / candidate
78
+ if not path.is_file():
79
+ continue
80
+ resolved = path.resolve()
81
+ try:
82
+ key = str(resolved.relative_to(base.resolve()))
83
+ except ValueError:
84
+ key = str(resolved)
85
+ hashes[key] = sha256_file(resolved)
86
+ return dict(sorted(hashes.items()))
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import replace
5
+ from typing import Any, Literal
6
+
7
+ from whiletrue.core.events import WtEvent
8
+ from whiletrue.core.history import (
9
+ CommentRecord,
10
+ Decision,
11
+ EffectRecord,
12
+ LearningRecord,
13
+ LearningStatus,
14
+ ParkRecord,
15
+ SteerRecord,
16
+ )
17
+ from whiletrue.core.outcomes import ItemState
18
+ from whiletrue.core.store import ItemRecord, RunRecord, StepRecord
19
+
20
+
21
+ class MemoryStore:
22
+ """In-memory StateStore for tests and throwaway runs."""
23
+
24
+ def __init__(self) -> None:
25
+ self._runs: list[RunRecord] = []
26
+ self._items: list[ItemRecord] = []
27
+ self._steps: list[StepRecord] = []
28
+ self._events: list[WtEvent] = []
29
+ self._cache: dict[str, Any] = {}
30
+ self._parks: list[ParkRecord] = []
31
+ self._comments: list[CommentRecord] = []
32
+ self._steers: list[SteerRecord] = []
33
+ self._effects: dict[str, EffectRecord] = {}
34
+ self._learnings: list[LearningRecord] = []
35
+
36
+ def create_run(
37
+ self, *, id: str, pipeline_hash: str, started_at: int, lockfile: str | None = None
38
+ ) -> None:
39
+ self._runs.append(
40
+ RunRecord(
41
+ id=id,
42
+ pipeline_hash=pipeline_hash,
43
+ lockfile=lockfile,
44
+ status="running",
45
+ started_at=started_at,
46
+ ended_at=None,
47
+ )
48
+ )
49
+
50
+ def finish_run(
51
+ self, id: str, status: Literal["done", "failed", "parked", "cancelled"], ended_at: int
52
+ ) -> None:
53
+ run = self.get_run(id)
54
+ if run is None:
55
+ raise KeyError(f'No run "{id}"')
56
+ run.status = status
57
+ run.ended_at = ended_at
58
+
59
+ def interrupt_run(self, id: str, ended_at: int) -> None:
60
+ for step in self._steps:
61
+ if step.run_id == id and step.status == "running":
62
+ step.status, step.error, step.ended_at = "errored", "interrupted", ended_at
63
+ for item in self._items:
64
+ if item.run_id == id and item.state == "running":
65
+ item.state = "parked"
66
+ self.finish_run(id, "parked", ended_at)
67
+
68
+ def get_run(self, id: str) -> RunRecord | None:
69
+ return next((r for r in self._runs if r.id == id), None)
70
+
71
+ def list_runs(self) -> list[RunRecord]:
72
+ return sorted((replace(r) for r in self._runs), key=lambda r: r.started_at)
73
+
74
+ def latest_run(self) -> RunRecord | None:
75
+ return self._runs[-1] if self._runs else None
76
+
77
+ def upsert_item(self, item: ItemRecord) -> None:
78
+ stored = replace(item, depends_on=list(item.depends_on))
79
+ for i, existing in enumerate(self._items):
80
+ if existing.run_id == item.run_id and existing.id == item.id:
81
+ self._items[i] = stored
82
+ return
83
+ self._items.append(stored)
84
+
85
+ def set_item_state(self, run_id: str, item_id: str, state: ItemState) -> None:
86
+ for item in self._items:
87
+ if item.run_id == run_id and item.id == item_id:
88
+ item.state = state
89
+ return
90
+ raise KeyError(f'No item "{item_id}" in run "{run_id}"')
91
+
92
+ def get_item(self, run_id: str, item_id: str) -> ItemRecord | None:
93
+ found = next((i for i in self._items if i.run_id == run_id and i.id == item_id), None)
94
+ return replace(found) if found else None
95
+
96
+ def list_items(self, run_id: str) -> list[ItemRecord]:
97
+ return [replace(i) for i in self._items if i.run_id == run_id]
98
+
99
+ def start_step(
100
+ self,
101
+ *,
102
+ run_id: str,
103
+ item_id: str,
104
+ node_path: str,
105
+ attempt: int,
106
+ input: Any,
107
+ started_at: int,
108
+ ) -> int:
109
+ step_id = len(self._steps) + 1
110
+ self._steps.append(
111
+ StepRecord(
112
+ id=step_id,
113
+ run_id=run_id,
114
+ item_id=item_id,
115
+ node_path=node_path,
116
+ attempt=attempt,
117
+ status="running",
118
+ port=None,
119
+ input=input,
120
+ output=None,
121
+ cost_usd=0,
122
+ error=None,
123
+ started_at=started_at,
124
+ ended_at=None,
125
+ )
126
+ )
127
+ return step_id
128
+
129
+ def finish_step(
130
+ self,
131
+ step_id: int,
132
+ *,
133
+ status: Literal["completed", "errored"],
134
+ ended_at: int,
135
+ port: str | None = None,
136
+ output: Any = None,
137
+ cost_usd: float = 0,
138
+ error: str | None = None,
139
+ ) -> None:
140
+ step = next((s for s in self._steps if s.id == step_id), None)
141
+ if step is None:
142
+ raise KeyError(f"No step {step_id}")
143
+ step.status = status
144
+ step.port = port
145
+ step.output = output
146
+ step.cost_usd = cost_usd
147
+ step.error = error
148
+ step.ended_at = ended_at
149
+
150
+ def list_steps(self, run_id: str, item_id: str | None = None) -> list[StepRecord]:
151
+ return [
152
+ replace(s)
153
+ for s in self._steps
154
+ if s.run_id == run_id and (item_id is None or s.item_id == item_id)
155
+ ]
156
+
157
+ def append_event(self, event: WtEvent) -> None:
158
+ self._events.append(copy.copy(event))
159
+
160
+ def list_events(
161
+ self, run_id: str, *, item_id: str | None = None, type: str | None = None
162
+ ) -> list[WtEvent]:
163
+ return [
164
+ e
165
+ for e in self._events
166
+ if e.run_id == run_id
167
+ and (item_id is None or e.item_id == item_id)
168
+ and (type is None or e.type == type)
169
+ ]
170
+
171
+ def cache_get(self, key: str) -> Any | None:
172
+ return self._cache.get(key)
173
+
174
+ def cache_put(self, key: str, value: Any, created_at: int) -> None:
175
+ self._cache[key] = value
176
+
177
+ def close(self) -> None:
178
+ return None
179
+
180
+ # ---- human input and side effects ----
181
+
182
+ def create_park(
183
+ self,
184
+ *,
185
+ run_id: str,
186
+ item_id: str,
187
+ node_path: str,
188
+ what: str,
189
+ wake: dict[str, Any],
190
+ reason: str,
191
+ created_at: int,
192
+ ) -> ParkRecord:
193
+ park = ParkRecord(
194
+ id=len(self._parks) + 1,
195
+ run_id=run_id,
196
+ item_id=item_id,
197
+ node_path=node_path,
198
+ what=what,
199
+ wake=copy.deepcopy(wake),
200
+ reason=reason,
201
+ created_at=created_at,
202
+ )
203
+ self._parks.append(park)
204
+ return replace(park)
205
+
206
+ def _park(self, park_id: int) -> ParkRecord:
207
+ park = next((p for p in self._parks if p.id == park_id), None)
208
+ if park is None:
209
+ raise KeyError(f"No park {park_id}")
210
+ return park
211
+
212
+ def decide_park(
213
+ self,
214
+ park_id: int,
215
+ decision: Decision,
216
+ *,
217
+ decided_by: str,
218
+ decided_at: int,
219
+ notes: str | None,
220
+ ) -> None:
221
+ park = self._park(park_id)
222
+ park.decision, park.decided_by, park.decided_at, park.notes = (
223
+ decision,
224
+ decided_by,
225
+ decided_at,
226
+ notes,
227
+ )
228
+
229
+ def list_parks(
230
+ self,
231
+ item_id: str | None = None,
232
+ *,
233
+ node_path: str | None = None,
234
+ run_id: str | None = None,
235
+ undecided_only: bool = False,
236
+ ) -> list[ParkRecord]:
237
+ return [
238
+ replace(p)
239
+ for p in self._parks
240
+ if (item_id is None or p.item_id == item_id)
241
+ and (node_path is None or p.node_path == node_path)
242
+ and (run_id is None or p.run_id == run_id)
243
+ and (not undecided_only or p.decision is None)
244
+ ]
245
+
246
+ def add_comment(
247
+ self,
248
+ *,
249
+ item_id: str,
250
+ source: str,
251
+ external_id: str,
252
+ author: str,
253
+ body: str,
254
+ created_at: int,
255
+ thread: str | None = None,
256
+ url: str | None = None,
257
+ kind: Literal["human", "reviewer"] = "human",
258
+ ) -> CommentRecord | None:
259
+ if any(
260
+ c.item_id == item_id and c.source == source and c.external_id == external_id
261
+ for c in self._comments
262
+ ):
263
+ return None
264
+ comment = CommentRecord(
265
+ id=len(self._comments) + 1,
266
+ item_id=item_id,
267
+ source=source,
268
+ external_id=external_id,
269
+ author=author,
270
+ body=body,
271
+ created_at=created_at,
272
+ thread=thread,
273
+ url=url,
274
+ kind=kind,
275
+ )
276
+ self._comments.append(comment)
277
+ return replace(comment)
278
+
279
+ def list_comments(self, item_id: str, *, unanswered_only: bool = False) -> list[CommentRecord]:
280
+ return [
281
+ replace(c)
282
+ for c in self._comments
283
+ if c.item_id == item_id and not (unanswered_only and c.answered)
284
+ ]
285
+
286
+ def answer_comment(self, comment_id: int, reply: str) -> None:
287
+ comment = next((c for c in self._comments if c.id == comment_id), None)
288
+ if comment is None:
289
+ raise KeyError(f"No comment {comment_id}")
290
+ comment.answered, comment.reply = True, reply
291
+
292
+ def add_steer(
293
+ self, *, run_id: str, item_id: str, step: str, prompt: str, created_at: int
294
+ ) -> SteerRecord:
295
+ steer = SteerRecord(len(self._steers) + 1, run_id, item_id, step, prompt, created_at)
296
+ self._steers.append(steer)
297
+ return replace(steer)
298
+
299
+ def list_steers(self, item_id: str) -> list[SteerRecord]:
300
+ return [replace(s) for s in self._steers if s.item_id == item_id]
301
+
302
+ def effect_get(self, key: str) -> EffectRecord | None:
303
+ found = self._effects.get(key)
304
+ return None if found is None else replace(found, ref=copy.deepcopy(found.ref))
305
+
306
+ def effect_put(
307
+ self,
308
+ key: str,
309
+ *,
310
+ run_id: str,
311
+ status: Literal["started", "done"],
312
+ ref: Any,
313
+ created_at: int,
314
+ ) -> None:
315
+ self._effects[key] = EffectRecord(key, status, run_id, created_at, copy.deepcopy(ref))
316
+
317
+ def add_learning(
318
+ self,
319
+ *,
320
+ scope: str,
321
+ note: str,
322
+ status: LearningStatus,
323
+ created_at: int,
324
+ run_id: str | None = None,
325
+ provenance: dict[str, Any] | None = None,
326
+ ) -> LearningRecord:
327
+ learning = LearningRecord(
328
+ len(self._learnings) + 1,
329
+ scope,
330
+ note,
331
+ status,
332
+ created_at,
333
+ run_id,
334
+ copy.deepcopy(provenance or {}),
335
+ )
336
+ self._learnings.append(learning)
337
+ return replace(learning)
338
+
339
+ def list_learnings(
340
+ self, *, scope: str | None = None, status: LearningStatus | None = None
341
+ ) -> list[LearningRecord]:
342
+ return [
343
+ replace(item)
344
+ for item in self._learnings
345
+ if (scope is None or item.scope == scope) and (status is None or item.status == status)
346
+ ]
347
+
348
+ def set_learning_status(self, learning_id: int, status: LearningStatus) -> None:
349
+ item = next((x for x in self._learnings if x.id == learning_id), None)
350
+ if item is None:
351
+ raise KeyError(f"No learning {learning_id}")
352
+ item.status = status