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,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)
@@ -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)