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/store.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from whiletrue.core.events import WtEvent
|
|
7
|
+
from whiletrue.core.history import (
|
|
8
|
+
CommentRecord,
|
|
9
|
+
Decision,
|
|
10
|
+
EffectRecord,
|
|
11
|
+
LearningRecord,
|
|
12
|
+
LearningStatus,
|
|
13
|
+
ParkRecord,
|
|
14
|
+
SteerRecord,
|
|
15
|
+
)
|
|
16
|
+
from whiletrue.core.outcomes import ItemState
|
|
17
|
+
|
|
18
|
+
RunStatus = Literal["running", "done", "failed", "parked", "cancelled"]
|
|
19
|
+
StepStatus = Literal["running", "completed", "errored"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RunRecord:
|
|
24
|
+
id: str
|
|
25
|
+
pipeline_hash: str
|
|
26
|
+
lockfile: str | None
|
|
27
|
+
status: RunStatus
|
|
28
|
+
started_at: int
|
|
29
|
+
ended_at: int | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ItemRecord:
|
|
34
|
+
id: str
|
|
35
|
+
run_id: str
|
|
36
|
+
source_key: str
|
|
37
|
+
state: ItemState
|
|
38
|
+
payload: Any = None
|
|
39
|
+
depends_on: list[str] = field(default_factory=lambda: [])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class StepRecord:
|
|
44
|
+
id: int
|
|
45
|
+
run_id: str
|
|
46
|
+
item_id: str
|
|
47
|
+
node_path: str
|
|
48
|
+
attempt: int
|
|
49
|
+
status: StepStatus
|
|
50
|
+
#: Port the step fired (ok, fail, ...), once completed.
|
|
51
|
+
port: str | None
|
|
52
|
+
input: Any
|
|
53
|
+
output: Any
|
|
54
|
+
cost_usd: float
|
|
55
|
+
error: str | None
|
|
56
|
+
started_at: int
|
|
57
|
+
ended_at: int | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StateStore(Protocol):
|
|
61
|
+
"""Runtime source of truth for pipeline progress.
|
|
62
|
+
|
|
63
|
+
SQLite is the only v1 backend; everything else (GitHub, git, ...) is an optional integration
|
|
64
|
+
elsewhere.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def create_run(
|
|
68
|
+
self, *, id: str, pipeline_hash: str, started_at: int, lockfile: str | None = None
|
|
69
|
+
) -> None: ...
|
|
70
|
+
def finish_run(
|
|
71
|
+
self, id: str, status: Literal["done", "failed", "parked", "cancelled"], ended_at: int
|
|
72
|
+
) -> None: ...
|
|
73
|
+
def get_run(self, id: str) -> RunRecord | None: ...
|
|
74
|
+
def interrupt_run(self, id: str, ended_at: int) -> None:
|
|
75
|
+
"""A run whose process died: its running steps errored, running items parked, run parked."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def latest_run(self) -> RunRecord | None: ...
|
|
79
|
+
def list_runs(self) -> list[RunRecord]:
|
|
80
|
+
"""Every run, oldest first."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def upsert_item(self, item: ItemRecord) -> None: ...
|
|
84
|
+
def set_item_state(self, run_id: str, item_id: str, state: ItemState) -> None: ...
|
|
85
|
+
def list_items(self, run_id: str) -> list[ItemRecord]: ...
|
|
86
|
+
def get_item(self, run_id: str, item_id: str) -> ItemRecord | None: ...
|
|
87
|
+
|
|
88
|
+
def start_step(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
run_id: str,
|
|
92
|
+
item_id: str,
|
|
93
|
+
node_path: str,
|
|
94
|
+
attempt: int,
|
|
95
|
+
input: Any,
|
|
96
|
+
started_at: int,
|
|
97
|
+
) -> int: ...
|
|
98
|
+
def finish_step(
|
|
99
|
+
self,
|
|
100
|
+
step_id: int,
|
|
101
|
+
*,
|
|
102
|
+
status: Literal["completed", "errored"],
|
|
103
|
+
ended_at: int,
|
|
104
|
+
port: str | None = None,
|
|
105
|
+
output: Any = None,
|
|
106
|
+
cost_usd: float = 0,
|
|
107
|
+
error: str | None = None,
|
|
108
|
+
) -> None: ...
|
|
109
|
+
def list_steps(self, run_id: str, item_id: str | None = None) -> list[StepRecord]: ...
|
|
110
|
+
|
|
111
|
+
def append_event(self, event: WtEvent) -> None: ...
|
|
112
|
+
def list_events(
|
|
113
|
+
self, run_id: str, *, item_id: str | None = None, type: str | None = None
|
|
114
|
+
) -> list[WtEvent]: ...
|
|
115
|
+
|
|
116
|
+
def cache_get(self, key: str) -> Any | None:
|
|
117
|
+
"""The value stored under `key`, or None on a miss (callers wrap values so None is free)."""
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
def cache_put(self, key: str, value: Any, created_at: int) -> None: ...
|
|
121
|
+
|
|
122
|
+
# ---- human input and side effects (keyed by item, not run) ----
|
|
123
|
+
|
|
124
|
+
def create_park(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
run_id: str,
|
|
128
|
+
item_id: str,
|
|
129
|
+
node_path: str,
|
|
130
|
+
what: str,
|
|
131
|
+
wake: dict[str, Any],
|
|
132
|
+
reason: str,
|
|
133
|
+
created_at: int,
|
|
134
|
+
) -> ParkRecord: ...
|
|
135
|
+
def decide_park(
|
|
136
|
+
self,
|
|
137
|
+
park_id: int,
|
|
138
|
+
decision: Decision,
|
|
139
|
+
*,
|
|
140
|
+
decided_by: str,
|
|
141
|
+
decided_at: int,
|
|
142
|
+
notes: str | None,
|
|
143
|
+
) -> None: ...
|
|
144
|
+
def list_parks(
|
|
145
|
+
self,
|
|
146
|
+
item_id: str | None = None,
|
|
147
|
+
*,
|
|
148
|
+
node_path: str | None = None,
|
|
149
|
+
run_id: str | None = None,
|
|
150
|
+
undecided_only: bool = False,
|
|
151
|
+
) -> list[ParkRecord]:
|
|
152
|
+
"""Parks in creation order. The Nth execution of a gate uses the Nth of its parks."""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
def add_comment(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
item_id: str,
|
|
159
|
+
source: str,
|
|
160
|
+
external_id: str,
|
|
161
|
+
author: str,
|
|
162
|
+
body: str,
|
|
163
|
+
created_at: int,
|
|
164
|
+
thread: str | None = None,
|
|
165
|
+
url: str | None = None,
|
|
166
|
+
kind: Literal["human", "reviewer"] = "human",
|
|
167
|
+
) -> CommentRecord | None:
|
|
168
|
+
"""Stores a comment; None when (item, source, external id) was already recorded."""
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
def list_comments(
|
|
172
|
+
self, item_id: str, *, unanswered_only: bool = False
|
|
173
|
+
) -> list[CommentRecord]: ...
|
|
174
|
+
def answer_comment(self, comment_id: int, reply: str) -> None: ...
|
|
175
|
+
|
|
176
|
+
def add_steer(
|
|
177
|
+
self, *, run_id: str, item_id: str, step: str, prompt: str, created_at: int
|
|
178
|
+
) -> SteerRecord: ...
|
|
179
|
+
def list_steers(self, item_id: str) -> list[SteerRecord]: ...
|
|
180
|
+
|
|
181
|
+
def effect_get(self, key: str) -> EffectRecord | None: ...
|
|
182
|
+
def effect_put(
|
|
183
|
+
self,
|
|
184
|
+
key: str,
|
|
185
|
+
*,
|
|
186
|
+
run_id: str,
|
|
187
|
+
status: Literal["started", "done"],
|
|
188
|
+
ref: Any,
|
|
189
|
+
created_at: int,
|
|
190
|
+
) -> None: ...
|
|
191
|
+
|
|
192
|
+
def add_learning(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
scope: str,
|
|
196
|
+
note: str,
|
|
197
|
+
status: LearningStatus,
|
|
198
|
+
created_at: int,
|
|
199
|
+
run_id: str | None = None,
|
|
200
|
+
provenance: dict[str, Any] | None = None,
|
|
201
|
+
) -> LearningRecord: ...
|
|
202
|
+
def list_learnings(
|
|
203
|
+
self, *, scope: str | None = None, status: LearningStatus | None = None
|
|
204
|
+
) -> list[LearningRecord]: ...
|
|
205
|
+
def set_learning_status(self, learning_id: int, status: LearningStatus) -> None: ...
|
|
206
|
+
|
|
207
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TemplateError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_TOKEN = re.compile(r"\{\{\s*([^{}]*?)\s*\}\}")
|
|
14
|
+
_PATH = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _lookup(scope: Any, path: str) -> Any:
|
|
18
|
+
current: Any = scope
|
|
19
|
+
for key in path.split("."):
|
|
20
|
+
if isinstance(current, Mapping) and key in current:
|
|
21
|
+
current = cast(Mapping[str, Any], current)[key]
|
|
22
|
+
elif (
|
|
23
|
+
isinstance(current, list) and key.isdigit() and int(key) < len(cast(list[Any], current))
|
|
24
|
+
):
|
|
25
|
+
current = cast(list[Any], current)[int(key)]
|
|
26
|
+
else:
|
|
27
|
+
raise TemplateError(f'Template variable "{path}" is not defined')
|
|
28
|
+
return current
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _stringify(value: Any) -> str:
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return value
|
|
34
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_template(template: str, scope: Any) -> str:
|
|
38
|
+
"""Replaces `{{dotted.path}}` with values from `scope`. Strict: a missing variable raises.
|
|
39
|
+
|
|
40
|
+
Objects are JSON-encoded. (Full expressions arrive with the expression-language item.)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def substitute(match: re.Match[str]) -> str:
|
|
44
|
+
expr = match.group(1)
|
|
45
|
+
if not _PATH.match(expr):
|
|
46
|
+
raise TemplateError(f'Unsupported template expression "{expr}"')
|
|
47
|
+
return _stringify(_lookup(scope, expr))
|
|
48
|
+
|
|
49
|
+
return _TOKEN.sub(substitute, template)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_deep(value: Any, scope: Any) -> Any:
|
|
53
|
+
"""Renders every string leaf of a JSON-like value."""
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return render_template(value, scope)
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return [render_deep(v, scope) for v in value] # pyright: ignore[reportUnknownVariableType]
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {k: render_deep(v, scope) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType]
|
|
60
|
+
return value
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from whiletrue.core.config import PipelineConfig, PipelineNode, SourceLoc
|
|
10
|
+
from whiletrue.core.module import ConfigIssue, PortSpec, ValidateContext
|
|
11
|
+
from whiletrue.core.registry import Registry
|
|
12
|
+
from whiletrue.core.source import SourceError, source_spec
|
|
13
|
+
|
|
14
|
+
Severity = Literal["error", "warning"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Diagnostic:
|
|
19
|
+
severity: Severity
|
|
20
|
+
message: str
|
|
21
|
+
node_id: str
|
|
22
|
+
loc: SourceLoc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_errors(diagnostics: list[Diagnostic]) -> bool:
|
|
26
|
+
return any(d.severity == "error" for d in diagnostics)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_diagnostic(d: Diagnostic) -> str:
|
|
30
|
+
return f"{d.loc.file}:{d.loc.line}:{d.loc.col}: {d.severity}: {d.message} [{d.node_id}]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _first_port(ports: list[PortSpec], preferred: str) -> PortSpec | None:
|
|
34
|
+
return next((p for p in ports if p.name == preferred), ports[0] if ports else None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_pipeline(
|
|
38
|
+
config: PipelineConfig, registry: Registry, *, cwd: str | None = None
|
|
39
|
+
) -> list[Diagnostic]:
|
|
40
|
+
"""Static validation: everything that can be known before any tokens are spent.
|
|
41
|
+
|
|
42
|
+
Resolves modules, checks config schemas, wiring caps and the safety rules.
|
|
43
|
+
"""
|
|
44
|
+
vctx = ValidateContext(cwd=cwd if cwd is not None else os.getcwd(), registry=registry)
|
|
45
|
+
out: list[Diagnostic] = []
|
|
46
|
+
try:
|
|
47
|
+
source = source_spec(config.extra)
|
|
48
|
+
except SourceError as error:
|
|
49
|
+
out.append(Diagnostic("error", str(error), config.root.id, config.root.loc))
|
|
50
|
+
else:
|
|
51
|
+
if source is not None and registry.get_provider("source", source[0]) is None:
|
|
52
|
+
out.append(
|
|
53
|
+
Diagnostic(
|
|
54
|
+
"error", f'Unknown source "{source[0]}"', config.root.id, config.root.loc
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def push(node: PipelineNode, severity: Severity, message: str) -> None:
|
|
59
|
+
out.append(Diagnostic(severity, message, node.id, node.loc))
|
|
60
|
+
|
|
61
|
+
def push_issues(node: PipelineNode, issues: list[ConfigIssue]) -> None:
|
|
62
|
+
for issue in issues:
|
|
63
|
+
push(node, issue.severity, issue.message)
|
|
64
|
+
|
|
65
|
+
def declared(node: PipelineNode, direction: Literal["in", "out"]) -> PortSpec | None:
|
|
66
|
+
cap = node.ports.get(direction) if node.ports else None
|
|
67
|
+
return PortSpec(direction, cap) if cap is not None and registry.caps.has(cap) else None
|
|
68
|
+
|
|
69
|
+
def in_port(node: PipelineNode) -> PortSpec | None:
|
|
70
|
+
resolved = registry.resolve(node.use)
|
|
71
|
+
if node.ports is not None:
|
|
72
|
+
return declared(node, "in")
|
|
73
|
+
if resolved is None or resolved.kind != "action":
|
|
74
|
+
return None
|
|
75
|
+
return _first_port(resolved.module.ports.inputs, "in")
|
|
76
|
+
|
|
77
|
+
def out_port(node: PipelineNode) -> PortSpec | None:
|
|
78
|
+
resolved = registry.resolve(node.use)
|
|
79
|
+
if node.ports is not None:
|
|
80
|
+
return declared(node, "out")
|
|
81
|
+
if resolved is None or resolved.kind != "action":
|
|
82
|
+
return None
|
|
83
|
+
return _first_port(resolved.module.ports.outputs, "out")
|
|
84
|
+
|
|
85
|
+
def visit(node: PipelineNode) -> None:
|
|
86
|
+
resolved = registry.resolve(node.use)
|
|
87
|
+
if resolved is None:
|
|
88
|
+
known = registry.ids()
|
|
89
|
+
suffix = f" (known: {', '.join(known)})" if known else ""
|
|
90
|
+
push(node, "error", f'Unknown module "{node.use}"{suffix}')
|
|
91
|
+
else:
|
|
92
|
+
module = resolved.module
|
|
93
|
+
try:
|
|
94
|
+
parsed = module.config.model_validate(node.config)
|
|
95
|
+
except ValidationError as error:
|
|
96
|
+
for issue in error.errors():
|
|
97
|
+
loc = ".".join(str(p) for p in issue["loc"])
|
|
98
|
+
at = f' at "{loc}"' if loc else ""
|
|
99
|
+
push(node, "error", f'Invalid config for "{node.use}": {issue["msg"]}{at}')
|
|
100
|
+
else:
|
|
101
|
+
if module.validate is not None:
|
|
102
|
+
push_issues(node, module.validate(parsed, vctx))
|
|
103
|
+
if resolved.kind == "strategy" and module.validate_node is not None:
|
|
104
|
+
push_issues(node, module.validate_node(parsed, node, vctx))
|
|
105
|
+
|
|
106
|
+
if resolved.kind == "action":
|
|
107
|
+
if node.children:
|
|
108
|
+
push(
|
|
109
|
+
node, "error", f'Action module "{node.use}" cannot have child steps (`do`)'
|
|
110
|
+
)
|
|
111
|
+
if module.id.startswith("gate.") and module.effects != "deterministic":
|
|
112
|
+
push(node, "error", f'Gate "{module.id}" must be deterministic')
|
|
113
|
+
if module.effects == "side-effect" and module.idempotency_key is None:
|
|
114
|
+
push(
|
|
115
|
+
node,
|
|
116
|
+
"error",
|
|
117
|
+
f'Side-effect module "{module.id}" must provide an idempotencyKey',
|
|
118
|
+
)
|
|
119
|
+
elif module.chain:
|
|
120
|
+
for prev, nxt in zip(node.children, node.children[1:], strict=False):
|
|
121
|
+
source = out_port(prev)
|
|
122
|
+
target = in_port(nxt)
|
|
123
|
+
if (
|
|
124
|
+
source is not None
|
|
125
|
+
and target is not None
|
|
126
|
+
and not registry.caps.compatible(source.cap, target.cap)
|
|
127
|
+
):
|
|
128
|
+
push(
|
|
129
|
+
nxt,
|
|
130
|
+
"error",
|
|
131
|
+
f'Cannot wire "{prev.id}".{source.name} ({source.cap}) '
|
|
132
|
+
f'into "{nxt.id}".{target.name} ({target.cap})',
|
|
133
|
+
)
|
|
134
|
+
if node.ports is not None:
|
|
135
|
+
for name, cap in node.ports.items():
|
|
136
|
+
if not registry.caps.has(cap):
|
|
137
|
+
push(node, "error", f'Composite port "{name}" declares unknown cap "{cap}"')
|
|
138
|
+
for child in node.children:
|
|
139
|
+
visit(child)
|
|
140
|
+
|
|
141
|
+
visit(config.root)
|
|
142
|
+
return out
|
whiletrue/core/wake.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Wake conditions: what ends a human gate's wait.
|
|
2
|
+
|
|
3
|
+
A person can always decide through the CLI (`cli`). Other conditions are checked by providers
|
|
4
|
+
registered under kind `wake` (a file appearing, a timer, a GitHub review, a plugin's own check).
|
|
5
|
+
A checker looks at the world and returns a decision, or None to keep waiting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from whiletrue.core.history import Decision, ParkRecord
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from whiletrue.core.registry import Registry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class WakeCondition:
|
|
23
|
+
name: str
|
|
24
|
+
#: The condition's argument (`approved`, a path, `10m`, ...), or None.
|
|
25
|
+
arg: Any = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class WakeSpec:
|
|
30
|
+
mode: Literal["any", "all"]
|
|
31
|
+
#: Conditions that are checked automatically (everything except `cli`).
|
|
32
|
+
conditions: tuple[WakeCondition, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class WakeResult:
|
|
37
|
+
decision: Decision
|
|
38
|
+
note: str | None = None
|
|
39
|
+
#: Who or what decided: `github`, `file_exists`, ...
|
|
40
|
+
by: str = "wake"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class WakeContext:
|
|
45
|
+
cwd: str
|
|
46
|
+
now: Callable[[], int]
|
|
47
|
+
item_id: str
|
|
48
|
+
park: ParkRecord
|
|
49
|
+
registry: Registry
|
|
50
|
+
#: The item's payload (issue number, PR url once known, ...).
|
|
51
|
+
item: dict[str, Any]
|
|
52
|
+
#: Whatever the step before the gate produced.
|
|
53
|
+
input: Any = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WakeChecker(Protocol):
|
|
57
|
+
async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_wake(raw: Any) -> WakeSpec:
|
|
61
|
+
"""`cli`, `[cli, ...]`, `{any: [...]}` or `{all: [...]}`; each entry is a name or `{name: arg}`.
|
|
62
|
+
|
|
63
|
+
Raises ValueError with a message fit for a config error.
|
|
64
|
+
"""
|
|
65
|
+
mode: Literal["any", "all"] = "any"
|
|
66
|
+
entries: Any = raw
|
|
67
|
+
if isinstance(raw, dict):
|
|
68
|
+
keys = list(cast(dict[str, Any], raw))
|
|
69
|
+
if len(keys) != 1 or keys[0] not in ("any", "all"):
|
|
70
|
+
raise ValueError("`wake` must be a list, or a mapping with a single `any` or `all` key")
|
|
71
|
+
mode = keys[0]
|
|
72
|
+
entries = cast(dict[str, Any], raw)[mode]
|
|
73
|
+
if isinstance(entries, str):
|
|
74
|
+
entries = [entries]
|
|
75
|
+
if not isinstance(entries, list) or len(cast(list[Any], entries)) == 0:
|
|
76
|
+
raise ValueError("`wake` needs at least one condition")
|
|
77
|
+
conditions: list[WakeCondition] = []
|
|
78
|
+
for entry in entries: # pyright: ignore[reportUnknownVariableType]
|
|
79
|
+
if isinstance(entry, str):
|
|
80
|
+
name, arg = entry, None
|
|
81
|
+
elif isinstance(entry, dict) and len(entry) == 1: # pyright: ignore[reportUnknownArgumentType]
|
|
82
|
+
((name, arg),) = entry.items() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError("A wake condition is a name or a single `name: argument` mapping")
|
|
85
|
+
if name != "cli":
|
|
86
|
+
conditions.append(WakeCondition(str(name), arg)) # pyright: ignore[reportUnknownArgumentType]
|
|
87
|
+
return WakeSpec(mode, tuple(conditions))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def wake_to_dict(spec: WakeSpec) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"mode": spec.mode,
|
|
93
|
+
"conditions": [{"name": c.name, "arg": c.arg} for c in spec.conditions],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def evaluate_wake(spec: WakeSpec, ctx: WakeContext) -> WakeResult | None:
|
|
98
|
+
"""The automatic decision, if the conditions are met (`cli` decisions are the gate's job)."""
|
|
99
|
+
if not spec.conditions:
|
|
100
|
+
return None
|
|
101
|
+
results: list[WakeResult] = []
|
|
102
|
+
for condition in spec.conditions:
|
|
103
|
+
checker: WakeChecker | None = ctx.registry.get_provider("wake", condition.name)
|
|
104
|
+
if checker is None:
|
|
105
|
+
continue
|
|
106
|
+
result = await checker.check(condition.arg, ctx)
|
|
107
|
+
if result is None:
|
|
108
|
+
if spec.mode == "all":
|
|
109
|
+
return None
|
|
110
|
+
continue
|
|
111
|
+
if spec.mode == "any":
|
|
112
|
+
return result
|
|
113
|
+
results.append(result)
|
|
114
|
+
if spec.mode == "all" and results:
|
|
115
|
+
rejected = next((r for r in results if r.decision == "rejected"), None)
|
|
116
|
+
return rejected or results[0]
|
|
117
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Workspaces give each item its own working directory (a git worktree, a copy, or the project)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from whiletrue.core.outcomes import ItemState
|
|
11
|
+
from whiletrue.core.registry import Registry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkspaceError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Workspace:
|
|
20
|
+
#: The directory the item's steps run in.
|
|
21
|
+
path: str
|
|
22
|
+
#: Called once when the item reaches a final state for this run (`done` may clean up; a
|
|
23
|
+
#: parked item keeps its workspace so a resume continues in it).
|
|
24
|
+
release: Callable[[ItemState], Awaitable[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkspaceProvider(Protocol):
|
|
28
|
+
async def create(self, config: dict[str, Any], item: dict[str, Any], cwd: str) -> Workspace:
|
|
29
|
+
"""Creates the workspace, or reuses it if it already exists (resume)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def workspace_spec(extra: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
|
34
|
+
"""`workspace: {name: {...config}}` -> (name, config). None means the project directory."""
|
|
35
|
+
raw = extra.get("workspace")
|
|
36
|
+
if raw is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(raw, str):
|
|
39
|
+
return raw, {}
|
|
40
|
+
if not isinstance(raw, dict) or len(raw) != 1: # pyright: ignore[reportUnknownArgumentType]
|
|
41
|
+
raise WorkspaceError("`workspace` must be a name or a mapping with one provider")
|
|
42
|
+
((name, config),) = raw.items() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
43
|
+
if config is not None and not isinstance(config, dict):
|
|
44
|
+
raise WorkspaceError(f"`workspace.{name}` must be a mapping")
|
|
45
|
+
return str(name), dict(config or {}) # pyright: ignore[reportUnknownArgumentType]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_workspace(registry: Registry, extra: dict[str, Any]) -> WorkspaceProvider | None:
|
|
49
|
+
spec = workspace_spec(extra)
|
|
50
|
+
if spec is None:
|
|
51
|
+
return None
|
|
52
|
+
provider: WorkspaceProvider | None = registry.get_provider("workspace", spec[0])
|
|
53
|
+
if provider is None:
|
|
54
|
+
raise WorkspaceError(f'Unknown workspace "{spec[0]}"')
|
|
55
|
+
return provider
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Git workspaces (worktree per item) and commit/push/diff modules."""
|
|
2
|
+
|
|
3
|
+
from whiletrue.core import PluginApi, SimplePlugin
|
|
4
|
+
from whiletrue.git.modules import (
|
|
5
|
+
git_commit_module,
|
|
6
|
+
git_commit_push_module,
|
|
7
|
+
git_diff_module,
|
|
8
|
+
)
|
|
9
|
+
from whiletrue.git.workspaces import DirCopy, GitWorktree, NoWorkspace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _register(api: PluginApi) -> None:
|
|
13
|
+
api.register_provider("workspace", "git-worktree", GitWorktree())
|
|
14
|
+
api.register_provider("workspace", "dir-copy", DirCopy())
|
|
15
|
+
api.register_provider("workspace", "none", NoWorkspace())
|
|
16
|
+
api.register_action(git_commit_module)
|
|
17
|
+
api.register_action(git_commit_push_module)
|
|
18
|
+
api.register_action(git_diff_module)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
git_plugin = SimplePlugin(name="whiletrue.git", register=_register)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DirCopy",
|
|
25
|
+
"GitWorktree",
|
|
26
|
+
"NoWorkspace",
|
|
27
|
+
"git_commit_module",
|
|
28
|
+
"git_commit_push_module",
|
|
29
|
+
"git_diff_module",
|
|
30
|
+
"git_plugin",
|
|
31
|
+
]
|