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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from whiletrue.abort import AbortSignal
|
|
16
|
+
from whiletrue.core.config import PipelineConfig, PipelineNode
|
|
17
|
+
from whiletrue.core.context import (
|
|
18
|
+
CompositeContext,
|
|
19
|
+
Locks,
|
|
20
|
+
Logger,
|
|
21
|
+
Scope,
|
|
22
|
+
StepContext,
|
|
23
|
+
create_step_context,
|
|
24
|
+
)
|
|
25
|
+
from whiletrue.core.events import EventBus
|
|
26
|
+
from whiletrue.core.lock import collect_file_hashes
|
|
27
|
+
from whiletrue.core.module import Emit, PortValues
|
|
28
|
+
from whiletrue.core.outcomes import (
|
|
29
|
+
ItemState,
|
|
30
|
+
OutcomePolicy,
|
|
31
|
+
is_outcome_port,
|
|
32
|
+
item_state_for,
|
|
33
|
+
transition,
|
|
34
|
+
)
|
|
35
|
+
from whiletrue.core.registry import Registry
|
|
36
|
+
from whiletrue.core.store import ItemRecord, RunStatus, StateStore
|
|
37
|
+
from whiletrue.core.template import render_deep
|
|
38
|
+
from whiletrue.core.workspace import Workspace, resolve_workspace, workspace_spec
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RunItem:
|
|
43
|
+
id: str
|
|
44
|
+
payload: Any = None
|
|
45
|
+
#: Ids of items that must be done first; the item waits (stays `pending`) until they are.
|
|
46
|
+
depends_on: list[str] = field(default_factory=lambda: [])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ItemOutcome:
|
|
51
|
+
item_id: str
|
|
52
|
+
state: ItemState
|
|
53
|
+
port: str
|
|
54
|
+
value: Any = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class RunResult:
|
|
59
|
+
run_id: str
|
|
60
|
+
status: RunStatus
|
|
61
|
+
items: list[ItemOutcome] = field(default_factory=lambda: [])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def hash_pipeline(config: PipelineConfig) -> str:
|
|
65
|
+
def strip(node: PipelineNode) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"id": node.id,
|
|
68
|
+
"use": node.use,
|
|
69
|
+
"config": node.config,
|
|
70
|
+
"wires": node.wires,
|
|
71
|
+
"children": [strip(c) for c in node.children],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
payload = json.dumps(strip(config.root), sort_keys=True, separators=(",", ":"), default=str)
|
|
75
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cache_key(module: Any, parsed: Any, input: PortValues) -> str | None:
|
|
79
|
+
"""Cache key for pure/deterministic modules that don't read the workspace; else None."""
|
|
80
|
+
if module.effects not in ("pure", "deterministic") or not module.cacheable:
|
|
81
|
+
return None
|
|
82
|
+
payload = json.dumps(
|
|
83
|
+
[module.id, parsed.model_dump(mode="json"), input], sort_keys=True, default=str
|
|
84
|
+
)
|
|
85
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _noop_log(level: str, message: str, data: dict[str, Any] | None = None) -> None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _wall_clock_ms() -> int:
|
|
93
|
+
return int(time.time() * 1000)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _error_message(error: BaseException) -> str:
|
|
97
|
+
return str(error)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def run_pipeline(
|
|
101
|
+
*,
|
|
102
|
+
registry: Registry,
|
|
103
|
+
config: PipelineConfig,
|
|
104
|
+
store: StateStore,
|
|
105
|
+
bus: EventBus | None = None,
|
|
106
|
+
#: Defaults to a single implicit item named "main".
|
|
107
|
+
items: list[RunItem] | None = None,
|
|
108
|
+
cwd: str | None = None,
|
|
109
|
+
signal: AbortSignal | None = None,
|
|
110
|
+
run_id: str | None = None,
|
|
111
|
+
log: Logger | None = None,
|
|
112
|
+
now: Callable[[], int] = _wall_clock_ms,
|
|
113
|
+
#: Recorded on the run (e.g. a hash of whiletrue.lock).
|
|
114
|
+
lockfile: str | None = None,
|
|
115
|
+
#: Serve recorded results instead of running modules with effects (see `core.replay`).
|
|
116
|
+
replay: dict[tuple[str, str, int], tuple[str, Any]] | None = None,
|
|
117
|
+
#: With `replay`: run steps that have no recording instead of failing (continue a parked run).
|
|
118
|
+
resume: bool = False,
|
|
119
|
+
#: Gates wait for decisions instead of parking; defaults to the pipeline's `on_idle: watch`.
|
|
120
|
+
watch: bool | None = None,
|
|
121
|
+
#: The first run of a chain of resumes; a gate's parks belong to it. Defaults to this run.
|
|
122
|
+
lineage: str | None = None,
|
|
123
|
+
#: How many items run at once; defaults to the pipeline's `parallel` (1).
|
|
124
|
+
parallel: int | None = None,
|
|
125
|
+
) -> RunResult:
|
|
126
|
+
"""Runs each item through the module tree and persists every step.
|
|
127
|
+
|
|
128
|
+
Items are tokens: up to `parallel` run at once, an item waits for the items it depends on, and
|
|
129
|
+
a parked or waiting item holds no slot beyond its own while the others continue.
|
|
130
|
+
"""
|
|
131
|
+
bus = bus if bus is not None else EventBus()
|
|
132
|
+
run_id = run_id if run_id is not None else str(uuid.uuid4())
|
|
133
|
+
cwd = cwd if cwd is not None else os.getcwd()
|
|
134
|
+
signal = signal if signal is not None else AbortSignal()
|
|
135
|
+
log = log if log is not None else _noop_log
|
|
136
|
+
items = items if items is not None else [RunItem("main")]
|
|
137
|
+
policy = OutcomePolicy(on_fail=config.on_fail)
|
|
138
|
+
watching = config.on_idle == "watch" if watch is None else watch
|
|
139
|
+
lineage = lineage if lineage is not None else run_id
|
|
140
|
+
locks = Locks()
|
|
141
|
+
slots = asyncio.Semaphore(max(1, parallel if parallel is not None else config.parallel))
|
|
142
|
+
agent_slots = asyncio.Semaphore(config.max_agents) if config.max_agents else None
|
|
143
|
+
|
|
144
|
+
pipeline_hash = hash_pipeline(config)
|
|
145
|
+
store.create_run(id=run_id, pipeline_hash=pipeline_hash, started_at=now(), lockfile=lockfile)
|
|
146
|
+
unsubscribe = bus.subscribe(store.append_event)
|
|
147
|
+
bus.emit(
|
|
148
|
+
"run.started", run_id, {"pipeline": config.name, "items": len(items), "lineage": lineage}
|
|
149
|
+
)
|
|
150
|
+
bus.emit(
|
|
151
|
+
"run.hashes",
|
|
152
|
+
run_id,
|
|
153
|
+
{"pipeline": pipeline_hash, "files": collect_file_hashes(config, cwd)},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
attempts: dict[str, int] = {}
|
|
157
|
+
|
|
158
|
+
for item in items:
|
|
159
|
+
store.upsert_item(
|
|
160
|
+
ItemRecord(
|
|
161
|
+
id=item.id,
|
|
162
|
+
run_id=run_id,
|
|
163
|
+
source_key=item.id,
|
|
164
|
+
state="ready",
|
|
165
|
+
payload=item.payload,
|
|
166
|
+
depends_on=list(item.depends_on),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
finished: dict[str, ItemState] = {}
|
|
171
|
+
cycle_ids = {i for cycle in find_dependency_cycles(items) for i in cycle}
|
|
172
|
+
results: dict[str, ItemOutcome] = {}
|
|
173
|
+
|
|
174
|
+
def block(item: RunItem, blocked_by: list[str]) -> None:
|
|
175
|
+
finished[item.id] = "pending"
|
|
176
|
+
store.set_item_state(run_id, item.id, "pending")
|
|
177
|
+
bus.emit(
|
|
178
|
+
"item.state", run_id, {"state": "pending", "blocked_by": blocked_by}, item_id=item.id
|
|
179
|
+
)
|
|
180
|
+
results[item.id] = ItemOutcome(item.id, "pending", "skip", {"blocked_by": blocked_by})
|
|
181
|
+
|
|
182
|
+
async def run_item(item: RunItem) -> None:
|
|
183
|
+
item_scope = Scope()
|
|
184
|
+
item_scope.set("run", {"id": run_id})
|
|
185
|
+
#: Agent session ids shared across this item's steps (`session: item` or a name).
|
|
186
|
+
item_scope.set("sessions", {})
|
|
187
|
+
raw_payload: Any = item.payload
|
|
188
|
+
fields: dict[str, Any] = {}
|
|
189
|
+
if isinstance(raw_payload, dict):
|
|
190
|
+
fields = {str(k): v for k, v in cast(dict[Any, Any], raw_payload).items()}
|
|
191
|
+
item_scope.set("item", {"id": item.id, **fields})
|
|
192
|
+
|
|
193
|
+
current: list[ItemState] = ["ready"]
|
|
194
|
+
cwd_cell = [cwd]
|
|
195
|
+
workspace: Workspace | None = None
|
|
196
|
+
|
|
197
|
+
def set_state(state: ItemState, item_id: str = item.id) -> None:
|
|
198
|
+
current[0] = transition(current[0], state)
|
|
199
|
+
store.set_item_state(run_id, item_id, state)
|
|
200
|
+
bus.emit("item.state", run_id, {"state": state}, item_id=item_id)
|
|
201
|
+
|
|
202
|
+
def cancelled_in_store(item_id: str = item.id) -> bool:
|
|
203
|
+
record = store.get_item(run_id, item_id)
|
|
204
|
+
return record is not None and record.state == "cancelled"
|
|
205
|
+
|
|
206
|
+
async def execute(
|
|
207
|
+
node: PipelineNode,
|
|
208
|
+
input: PortValues,
|
|
209
|
+
parent_scope: Scope,
|
|
210
|
+
item_id: str = item.id,
|
|
211
|
+
) -> Emit:
|
|
212
|
+
resolved = registry.resolve(node.use)
|
|
213
|
+
key = f"{item_id}:{node.id}"
|
|
214
|
+
attempt = attempts.get(key, 0) + 1
|
|
215
|
+
attempts[key] = attempt
|
|
216
|
+
|
|
217
|
+
step_id = store.start_step(
|
|
218
|
+
run_id=run_id,
|
|
219
|
+
item_id=item_id,
|
|
220
|
+
node_path=node.id,
|
|
221
|
+
attempt=attempt,
|
|
222
|
+
input=input,
|
|
223
|
+
started_at=now(),
|
|
224
|
+
)
|
|
225
|
+
bus.emit(
|
|
226
|
+
"step.started",
|
|
227
|
+
run_id,
|
|
228
|
+
{"node": node.id, "use": node.use, "attempt": attempt},
|
|
229
|
+
item_id=item_id,
|
|
230
|
+
step_id=step_id,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
cost = 0.0
|
|
234
|
+
|
|
235
|
+
def record_cost(usd: float) -> None:
|
|
236
|
+
nonlocal cost
|
|
237
|
+
cost += usd
|
|
238
|
+
|
|
239
|
+
def finish(emit: Emit, errored: str | None = None) -> Emit:
|
|
240
|
+
store.finish_step(
|
|
241
|
+
step_id,
|
|
242
|
+
status="errored" if errored else "completed",
|
|
243
|
+
port=emit.port,
|
|
244
|
+
output=emit.value,
|
|
245
|
+
cost_usd=cost,
|
|
246
|
+
error=errored,
|
|
247
|
+
ended_at=now(),
|
|
248
|
+
)
|
|
249
|
+
data: dict[str, Any] = {"node": node.id, "port": emit.port, "cost_usd": cost}
|
|
250
|
+
if errored:
|
|
251
|
+
data["error"] = errored
|
|
252
|
+
bus.emit(
|
|
253
|
+
"step.error" if errored else "step.finished",
|
|
254
|
+
run_id,
|
|
255
|
+
data,
|
|
256
|
+
item_id=item_id,
|
|
257
|
+
step_id=step_id,
|
|
258
|
+
)
|
|
259
|
+
return emit
|
|
260
|
+
|
|
261
|
+
def fail(message: str) -> Emit:
|
|
262
|
+
return finish(Emit("fail", {"error": message}), message)
|
|
263
|
+
|
|
264
|
+
if signal.aborted or cancelled_in_store():
|
|
265
|
+
return fail("cancelled")
|
|
266
|
+
if resolved is None:
|
|
267
|
+
return fail(f'Unknown module "{node.use}"')
|
|
268
|
+
|
|
269
|
+
scope = parent_scope.child()
|
|
270
|
+
module = resolved.module
|
|
271
|
+
try:
|
|
272
|
+
rendered = render_deep(node.config, {**scope.snapshot(), "input": input})
|
|
273
|
+
try:
|
|
274
|
+
parsed = module.config.model_validate(rendered)
|
|
275
|
+
except ValueError as error: # pydantic.ValidationError is a ValueError
|
|
276
|
+
return fail(f'Invalid config for "{node.use}": {_validation_summary(error)}')
|
|
277
|
+
base: dict[str, Any] = {
|
|
278
|
+
"run_id": run_id,
|
|
279
|
+
"item_id": item_id,
|
|
280
|
+
"node_id": node.id,
|
|
281
|
+
"attempt": attempt,
|
|
282
|
+
"cwd": cwd_cell[0],
|
|
283
|
+
"scope": scope,
|
|
284
|
+
"registry": registry,
|
|
285
|
+
"bus": bus,
|
|
286
|
+
"log": log,
|
|
287
|
+
"signal": signal,
|
|
288
|
+
"record_cost": record_cost,
|
|
289
|
+
"step_key": f"{run_id}:{item_id}:{node.id}",
|
|
290
|
+
"store": store,
|
|
291
|
+
"now": now,
|
|
292
|
+
"watch": watching,
|
|
293
|
+
"lineage": lineage,
|
|
294
|
+
"locks": locks,
|
|
295
|
+
"agent_slots": agent_slots,
|
|
296
|
+
}
|
|
297
|
+
if resolved.kind == "action":
|
|
298
|
+
cache_key = (
|
|
299
|
+
_cache_key(module, parsed, input)
|
|
300
|
+
if config.cache and replay is None
|
|
301
|
+
else None
|
|
302
|
+
)
|
|
303
|
+
hit = store.cache_get(cache_key) if cache_key else None
|
|
304
|
+
replaying = (
|
|
305
|
+
replay is not None
|
|
306
|
+
and module.effects != "pure"
|
|
307
|
+
and not scope.get("no_replay")
|
|
308
|
+
)
|
|
309
|
+
recorded = (
|
|
310
|
+
replay.get((item_id, node.id, attempt))
|
|
311
|
+
if replay is not None and replaying
|
|
312
|
+
else None
|
|
313
|
+
)
|
|
314
|
+
if replaying and recorded is None and not resume:
|
|
315
|
+
return fail(f"Replay diverged: no recorded result for {node.id}#{attempt}")
|
|
316
|
+
if recorded is not None:
|
|
317
|
+
emit = Emit(recorded[0], recorded[1])
|
|
318
|
+
bus.emit(
|
|
319
|
+
"step.replayed",
|
|
320
|
+
run_id,
|
|
321
|
+
{"node": node.id},
|
|
322
|
+
item_id=item_id,
|
|
323
|
+
step_id=step_id,
|
|
324
|
+
)
|
|
325
|
+
elif hit is not None:
|
|
326
|
+
emit = Emit(hit["port"], hit["value"])
|
|
327
|
+
bus.emit(
|
|
328
|
+
"step.cached",
|
|
329
|
+
run_id,
|
|
330
|
+
{"node": node.id},
|
|
331
|
+
item_id=item_id,
|
|
332
|
+
step_id=step_id,
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
step_context = create_step_context(parsed, **base)
|
|
336
|
+
emit = await _run_action(module, input, step_context, bus, step_id)
|
|
337
|
+
if cache_key and emit.port == "ok":
|
|
338
|
+
store.cache_put(
|
|
339
|
+
cache_key, {"port": emit.port, "value": emit.value}, now()
|
|
340
|
+
)
|
|
341
|
+
out_port = next((p for p in module.ports.outputs if p.name == "out"), None)
|
|
342
|
+
if emit.port == "ok" and emit.value is not None and out_port is not None:
|
|
343
|
+
check = registry.caps.validate(out_port.cap, emit.value)
|
|
344
|
+
if not check.ok:
|
|
345
|
+
return fail(
|
|
346
|
+
f'Output of "{node.use}" does not match cap {out_port.cap}: '
|
|
347
|
+
+ "; ".join(check.errors)
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
step_ctx = create_step_context(parsed, **base)
|
|
351
|
+
|
|
352
|
+
async def run_child(
|
|
353
|
+
child: PipelineNode,
|
|
354
|
+
child_input: PortValues,
|
|
355
|
+
child_scope: Scope | None = None,
|
|
356
|
+
/,
|
|
357
|
+
) -> Emit:
|
|
358
|
+
return await execute(child, child_input, child_scope or scope)
|
|
359
|
+
|
|
360
|
+
ctx = CompositeContext(
|
|
361
|
+
config=step_ctx.config,
|
|
362
|
+
run_id=step_ctx.run_id,
|
|
363
|
+
item_id=step_ctx.item_id,
|
|
364
|
+
node_id=step_ctx.node_id,
|
|
365
|
+
attempt=step_ctx.attempt,
|
|
366
|
+
cwd=step_ctx.cwd,
|
|
367
|
+
scope=step_ctx.scope,
|
|
368
|
+
registry=step_ctx.registry,
|
|
369
|
+
bus=step_ctx.bus,
|
|
370
|
+
log=step_ctx.log,
|
|
371
|
+
signal=step_ctx.signal,
|
|
372
|
+
record_cost=step_ctx.record_cost,
|
|
373
|
+
step_key=step_ctx.step_key,
|
|
374
|
+
store=store,
|
|
375
|
+
now=now,
|
|
376
|
+
watch=watching,
|
|
377
|
+
lineage=lineage,
|
|
378
|
+
locks=locks,
|
|
379
|
+
agent_slots=agent_slots,
|
|
380
|
+
policy=policy,
|
|
381
|
+
run=run_child,
|
|
382
|
+
)
|
|
383
|
+
emit = await module.run(node.children, input, ctx)
|
|
384
|
+
return finish(emit)
|
|
385
|
+
except Exception as error:
|
|
386
|
+
return fail(_error_message(error))
|
|
387
|
+
finally:
|
|
388
|
+
try:
|
|
389
|
+
await scope.dispose()
|
|
390
|
+
except Exception as error:
|
|
391
|
+
log("warn", f"Failed to release resources for {node.id}: {error}", None)
|
|
392
|
+
|
|
393
|
+
set_state("running")
|
|
394
|
+
try:
|
|
395
|
+
provider = resolve_workspace(registry, config.extra)
|
|
396
|
+
spec = workspace_spec(config.extra)
|
|
397
|
+
if provider is not None and spec is not None:
|
|
398
|
+
workspace = await provider.create(
|
|
399
|
+
render_deep(spec[1], item_scope.snapshot()), item_scope.get("item"), cwd
|
|
400
|
+
)
|
|
401
|
+
cwd_cell[0] = workspace.path
|
|
402
|
+
bus.emit(
|
|
403
|
+
"workspace.created",
|
|
404
|
+
run_id,
|
|
405
|
+
{"kind": spec[0], "path": workspace.path},
|
|
406
|
+
item_id=item.id,
|
|
407
|
+
)
|
|
408
|
+
emit = await execute(config.root, {}, item_scope)
|
|
409
|
+
except Exception as error:
|
|
410
|
+
emit = Emit("fail", {"error": f"workspace: {error}"})
|
|
411
|
+
await item_scope.dispose()
|
|
412
|
+
if signal.aborted or cancelled_in_store():
|
|
413
|
+
state: ItemState = "cancelled"
|
|
414
|
+
elif is_outcome_port(emit.port):
|
|
415
|
+
state = item_state_for(emit.port, config.on_fail)
|
|
416
|
+
else:
|
|
417
|
+
state = "done"
|
|
418
|
+
set_state(state)
|
|
419
|
+
if workspace is not None:
|
|
420
|
+
try:
|
|
421
|
+
await workspace.release(state)
|
|
422
|
+
bus.emit("workspace.released", run_id, {"state": state}, item_id=item.id)
|
|
423
|
+
except Exception as error:
|
|
424
|
+
log("warn", f"Failed to release the workspace of {item.id}: {error}", None)
|
|
425
|
+
finished[item.id] = state
|
|
426
|
+
results[item.id] = ItemOutcome(item.id, state, emit.port, emit.value)
|
|
427
|
+
|
|
428
|
+
done_events = {i.id: asyncio.Event() for i in items}
|
|
429
|
+
|
|
430
|
+
async def worker(item: RunItem) -> None:
|
|
431
|
+
try:
|
|
432
|
+
if item.id in cycle_ids:
|
|
433
|
+
block(item, ["dependency cycle"])
|
|
434
|
+
return
|
|
435
|
+
for dep in item.depends_on:
|
|
436
|
+
if dep in done_events:
|
|
437
|
+
await done_events[dep].wait()
|
|
438
|
+
blocked_by = [d for d in item.depends_on if finished.get(d) != "done"]
|
|
439
|
+
if blocked_by:
|
|
440
|
+
block(item, blocked_by)
|
|
441
|
+
return
|
|
442
|
+
async with slots:
|
|
443
|
+
await run_item(item)
|
|
444
|
+
finally:
|
|
445
|
+
done_events[item.id].set()
|
|
446
|
+
|
|
447
|
+
await asyncio.gather(*(worker(i) for i in items))
|
|
448
|
+
outcomes = [results[i.id] for i in items]
|
|
449
|
+
states = {o.state for o in outcomes}
|
|
450
|
+
status: RunStatus = (
|
|
451
|
+
"cancelled"
|
|
452
|
+
if "cancelled" in states
|
|
453
|
+
else "failed"
|
|
454
|
+
if "failed" in states
|
|
455
|
+
else "parked"
|
|
456
|
+
if states & {"parked", "pending"}
|
|
457
|
+
else "done"
|
|
458
|
+
)
|
|
459
|
+
store.finish_run(run_id, status, now())
|
|
460
|
+
bus.emit("run.finished", run_id, {"status": status})
|
|
461
|
+
unsubscribe()
|
|
462
|
+
return RunResult(run_id=run_id, status=status, items=outcomes)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def _run_action(
|
|
466
|
+
module: Any, input: PortValues, ctx: StepContext[Any], bus: EventBus, step_id: int
|
|
467
|
+
) -> Emit:
|
|
468
|
+
"""Runs an action; journaled effects are looked up first and recorded after they succeed."""
|
|
469
|
+
if not (module.journal and module.idempotency_key is not None) or ctx.store is None:
|
|
470
|
+
return await module.run(input, ctx)
|
|
471
|
+
store, run_id, now = ctx.store, ctx.run_id, ctx.now
|
|
472
|
+
key = module.idempotency_key(input, ctx)
|
|
473
|
+
known = store.effect_get(key)
|
|
474
|
+
if known is not None and known.status == "done":
|
|
475
|
+
bus.emit(
|
|
476
|
+
"step.journaled",
|
|
477
|
+
run_id,
|
|
478
|
+
{"node": ctx.node_id, "key": key},
|
|
479
|
+
item_id=ctx.item_id,
|
|
480
|
+
step_id=step_id,
|
|
481
|
+
)
|
|
482
|
+
recorded: dict[str, Any] = known.ref
|
|
483
|
+
return Emit(recorded["port"], recorded["value"])
|
|
484
|
+
store.effect_put(key, run_id=run_id, status="started", ref=None, created_at=now())
|
|
485
|
+
emit = await module.run(input, ctx)
|
|
486
|
+
if emit.port == "ok":
|
|
487
|
+
store.effect_put(
|
|
488
|
+
key,
|
|
489
|
+
run_id=run_id,
|
|
490
|
+
status="done",
|
|
491
|
+
ref={"port": emit.port, "value": emit.value},
|
|
492
|
+
created_at=now(),
|
|
493
|
+
)
|
|
494
|
+
return emit
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def find_dependency_cycles(items: list[RunItem]) -> list[list[str]]:
|
|
498
|
+
"""Groups of items that wait on each other (including an item waiting on itself)."""
|
|
499
|
+
graph = {i.id: [d for d in i.depends_on if d in {x.id for x in items}] for i in items}
|
|
500
|
+
index: dict[str, int] = {}
|
|
501
|
+
low: dict[str, int] = {}
|
|
502
|
+
stack: list[str] = []
|
|
503
|
+
on_stack: set[str] = set()
|
|
504
|
+
cycles: list[list[str]] = []
|
|
505
|
+
counter = [0]
|
|
506
|
+
|
|
507
|
+
def visit(node: str) -> None:
|
|
508
|
+
index[node] = low[node] = counter[0]
|
|
509
|
+
counter[0] += 1
|
|
510
|
+
stack.append(node)
|
|
511
|
+
on_stack.add(node)
|
|
512
|
+
for dep in graph[node]:
|
|
513
|
+
if dep not in index:
|
|
514
|
+
visit(dep)
|
|
515
|
+
low[node] = min(low[node], low[dep])
|
|
516
|
+
elif dep in on_stack:
|
|
517
|
+
low[node] = min(low[node], index[dep])
|
|
518
|
+
if low[node] == index[node]:
|
|
519
|
+
group: list[str] = []
|
|
520
|
+
while True:
|
|
521
|
+
member = stack.pop()
|
|
522
|
+
on_stack.discard(member)
|
|
523
|
+
group.append(member)
|
|
524
|
+
if member == node:
|
|
525
|
+
break
|
|
526
|
+
if len(group) > 1 or node in graph[node]:
|
|
527
|
+
cycles.append(sorted(group))
|
|
528
|
+
|
|
529
|
+
for item in items:
|
|
530
|
+
if item.id not in index:
|
|
531
|
+
visit(item.id)
|
|
532
|
+
return sorted(cycles)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _validation_summary(error: ValueError) -> str:
|
|
536
|
+
if isinstance(error, ValidationError):
|
|
537
|
+
return "; ".join(str(e["msg"]) for e in error.errors())
|
|
538
|
+
return str(error)
|
whiletrue/core/source.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Sources turn a pipeline's `source:` block into the items it processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
6
|
+
|
|
7
|
+
from whiletrue.core.runtime import RunItem
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from whiletrue.core.registry import Registry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceProvider(Protocol):
|
|
14
|
+
async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourceError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def source_spec(extra: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
|
22
|
+
"""`source: {name: {...config}}` -> (name, config). None when the pipeline has no source."""
|
|
23
|
+
raw = extra.get("source")
|
|
24
|
+
if raw is None:
|
|
25
|
+
return None
|
|
26
|
+
if not isinstance(raw, dict) or len(raw) != 1: # pyright: ignore[reportUnknownArgumentType]
|
|
27
|
+
raise SourceError(
|
|
28
|
+
"`source` must be a mapping with exactly one provider, e.g. `json: {...}`"
|
|
29
|
+
)
|
|
30
|
+
((name, config),) = raw.items() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
31
|
+
if config is not None and not isinstance(config, dict):
|
|
32
|
+
raise SourceError(f"`source.{name}` must be a mapping")
|
|
33
|
+
return str(name), dict(config or {}) # pyright: ignore[reportUnknownArgumentType]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def fetch_items(registry: Registry, extra: dict[str, Any], cwd: str) -> list[RunItem] | None:
|
|
37
|
+
"""The items from the pipeline's source, or None when it has none (one implicit item)."""
|
|
38
|
+
spec = source_spec(extra)
|
|
39
|
+
if spec is None:
|
|
40
|
+
return None
|
|
41
|
+
name, config = spec
|
|
42
|
+
provider: SourceProvider | None = registry.get_provider("source", name)
|
|
43
|
+
if provider is None:
|
|
44
|
+
raise SourceError(f'Unknown source "{name}"')
|
|
45
|
+
return await provider.fetch(config, cwd)
|