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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/core/events.py
ADDED
|
@@ -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
|