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,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
8
|
+
|
|
9
|
+
from whiletrue.basics.process import run_command, tail
|
|
10
|
+
from whiletrue.core import (
|
|
11
|
+
ActionModule,
|
|
12
|
+
Emit,
|
|
13
|
+
Ports,
|
|
14
|
+
PortSpec,
|
|
15
|
+
StepContext,
|
|
16
|
+
define_action,
|
|
17
|
+
parse_duration,
|
|
18
|
+
)
|
|
19
|
+
from whiletrue.core.module import PortValues
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExecConfig(BaseModel):
|
|
23
|
+
command: str = Field(min_length=1)
|
|
24
|
+
args: list[str] = Field(default_factory=lambda: [])
|
|
25
|
+
cwd: str | None = None
|
|
26
|
+
env: dict[str, str] | None = None
|
|
27
|
+
timeout: str | int | float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Reply(BaseModel):
|
|
31
|
+
port: str | None = None
|
|
32
|
+
value: Any = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _run(input: PortValues, ctx: StepContext[ExecConfig]) -> Emit:
|
|
36
|
+
cfg = ctx.config
|
|
37
|
+
result = await run_command(
|
|
38
|
+
cfg.command,
|
|
39
|
+
cfg.args,
|
|
40
|
+
cwd=str((Path(ctx.cwd) / (cfg.cwd or ".")).resolve()),
|
|
41
|
+
env=cfg.env,
|
|
42
|
+
signal=ctx.signal,
|
|
43
|
+
timeout_ms=None if cfg.timeout is None else parse_duration(cfg.timeout),
|
|
44
|
+
stdin=json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"input": input.get("in"),
|
|
47
|
+
"config": ctx.config.model_dump(),
|
|
48
|
+
"item": ctx.scope.get("item"),
|
|
49
|
+
"step": {"id": ctx.node_id, "key": ctx.step_key, "attempt": ctx.attempt},
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
if result.code != 0:
|
|
54
|
+
code = "unknown" if result.code is None else result.code
|
|
55
|
+
return Emit(
|
|
56
|
+
"fail",
|
|
57
|
+
{
|
|
58
|
+
"error": f"`{cfg.command}` exited with code {code}",
|
|
59
|
+
"output": tail(result.stdout + result.stderr),
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
parsed = json.loads(result.stdout)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return Emit("fail", {"error": f"`{cfg.command}` did not print valid JSON on stdout"})
|
|
66
|
+
try:
|
|
67
|
+
reply = _Reply.model_validate(parsed)
|
|
68
|
+
except ValidationError:
|
|
69
|
+
return Emit("fail", {"error": f"`{cfg.command}` reply must be {{ port?, value? }}"})
|
|
70
|
+
return Emit(reply.port or "ok", reply.value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: Escape hatch: runs any executable that speaks JSON over stdio, so a module can be written in
|
|
74
|
+
#: any language. Receives `{ input, config, item, step }` on stdin; replies `{ port?, value? }`.
|
|
75
|
+
exec_module = define_action(
|
|
76
|
+
ActionModule(
|
|
77
|
+
id="exec",
|
|
78
|
+
config=ExecConfig,
|
|
79
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
80
|
+
effects="side-effect",
|
|
81
|
+
idempotency_key=lambda _input, ctx: f"exec:{ctx.step_key}",
|
|
82
|
+
run=_run,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from whiletrue.core import (
|
|
8
|
+
ActionModule,
|
|
9
|
+
Emit,
|
|
10
|
+
Ports,
|
|
11
|
+
PortSpec,
|
|
12
|
+
StateStore,
|
|
13
|
+
StepContext,
|
|
14
|
+
define_action,
|
|
15
|
+
)
|
|
16
|
+
from whiletrue.core.module import PortValues
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HistoryConfig(BaseModel):
|
|
20
|
+
#: `item`: this item's parks, decisions, comments and steers. `project`: learnings a person
|
|
21
|
+
#: approved. `all`: both.
|
|
22
|
+
scope: Literal["item", "project", "all"] = "item"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_history(store: StateStore, item_id: str, scope: str) -> str:
|
|
26
|
+
"""The human input an agent should know about, as plain text."""
|
|
27
|
+
sections: list[str] = []
|
|
28
|
+
if scope in ("item", "all"):
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
for park in store.list_parks(item_id):
|
|
31
|
+
if park.decision is not None:
|
|
32
|
+
note = f": {park.notes}" if park.notes else ""
|
|
33
|
+
lines.append(f"- At {park.node_path} ({park.what}) a person {park.decision}{note}")
|
|
34
|
+
for comment in store.list_comments(item_id):
|
|
35
|
+
lines.append(f"- {comment.author} commented: {comment.body}")
|
|
36
|
+
if comment.reply:
|
|
37
|
+
lines.append(f" answered: {comment.reply}")
|
|
38
|
+
for steer in store.list_steers(item_id):
|
|
39
|
+
lines.append(f"- A person steered {steer.step}: {steer.prompt}")
|
|
40
|
+
if lines:
|
|
41
|
+
sections.append("Human input on this item so far:\n" + "\n".join(lines))
|
|
42
|
+
if scope in ("project", "all"):
|
|
43
|
+
learned = store.list_learnings(status="approved")
|
|
44
|
+
if learned:
|
|
45
|
+
sections.append(
|
|
46
|
+
"Lessons this project has approved:\n" + "\n".join(f"- {n.note}" for n in learned)
|
|
47
|
+
)
|
|
48
|
+
return "\n\n".join(sections)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _context(_input: PortValues, ctx: StepContext[HistoryConfig]) -> Emit:
|
|
52
|
+
if ctx.store is None:
|
|
53
|
+
return Emit("fail", {"error": "context.history needs a state store"})
|
|
54
|
+
return Emit("ok", render_history(ctx.store, ctx.item_id, ctx.config.scope))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: Turns the item's human input (and approved project lessons) into text for the next agent.
|
|
58
|
+
context_history_module: ActionModule[HistoryConfig] = define_action(
|
|
59
|
+
ActionModule(
|
|
60
|
+
id="context.history",
|
|
61
|
+
config=HistoryConfig,
|
|
62
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Text")]),
|
|
63
|
+
effects="deterministic",
|
|
64
|
+
cacheable=False,
|
|
65
|
+
run=_context,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DistillConfig(BaseModel):
|
|
71
|
+
#: Where proposals land until a person approves them.
|
|
72
|
+
scope: Literal["project", "run"] = "project"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _distill(input: PortValues, ctx: StepContext[DistillConfig]) -> Emit:
|
|
76
|
+
"""Proposes lessons from rejections and steers. Nothing is used until a person approves it."""
|
|
77
|
+
store = ctx.store
|
|
78
|
+
if store is None:
|
|
79
|
+
return Emit("fail", {"error": "history.distill needs a state store"})
|
|
80
|
+
known = {n.note for n in store.list_learnings(scope=ctx.config.scope)}
|
|
81
|
+
proposed: list[int] = []
|
|
82
|
+
|
|
83
|
+
def propose(note: str, **provenance: object) -> None:
|
|
84
|
+
if note in known:
|
|
85
|
+
return
|
|
86
|
+
known.add(note)
|
|
87
|
+
record = store.add_learning(
|
|
88
|
+
scope=ctx.config.scope,
|
|
89
|
+
note=note,
|
|
90
|
+
status="proposed",
|
|
91
|
+
created_at=ctx.now(),
|
|
92
|
+
run_id=ctx.run_id,
|
|
93
|
+
provenance={"item": ctx.item_id, **provenance},
|
|
94
|
+
)
|
|
95
|
+
proposed.append(record.id)
|
|
96
|
+
|
|
97
|
+
for park in store.list_parks(ctx.item_id):
|
|
98
|
+
if park.decision == "rejected" and park.notes:
|
|
99
|
+
propose(
|
|
100
|
+
f"A reviewer rejected work with: {park.notes}", source="rejection", park=park.id
|
|
101
|
+
)
|
|
102
|
+
for steer in store.list_steers(ctx.item_id):
|
|
103
|
+
propose(f"A person steered the agent: {steer.prompt}", source="steer", steer=steer.id)
|
|
104
|
+
return Emit("ok", input.get("in") if input.get("in") is not None else {"proposed": proposed})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: Proposes learnings (with provenance) from rejections and steers; a person approves them.
|
|
108
|
+
history_distill_module: ActionModule[DistillConfig] = define_action(
|
|
109
|
+
ActionModule(
|
|
110
|
+
id="history.distill",
|
|
111
|
+
config=DistillConfig,
|
|
112
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
113
|
+
effects="deterministic",
|
|
114
|
+
cacheable=False,
|
|
115
|
+
run=_distill,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from whiletrue.core import (
|
|
9
|
+
CommentsContext,
|
|
10
|
+
CompositeContext,
|
|
11
|
+
ConfigIssue,
|
|
12
|
+
Emit,
|
|
13
|
+
ParkRecord,
|
|
14
|
+
PipelineNode,
|
|
15
|
+
Strategy,
|
|
16
|
+
ValidateContext,
|
|
17
|
+
WakeContext,
|
|
18
|
+
WakeResult,
|
|
19
|
+
WakeSpec,
|
|
20
|
+
define_strategy,
|
|
21
|
+
evaluate_wake,
|
|
22
|
+
parse_duration,
|
|
23
|
+
parse_wake,
|
|
24
|
+
sync_comments,
|
|
25
|
+
wake_to_dict,
|
|
26
|
+
)
|
|
27
|
+
from whiletrue.core.module import PortValues
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApproveConfig(BaseModel):
|
|
31
|
+
#: What is being decided; shown in `whiletrue status` and the control panel.
|
|
32
|
+
what: str = "approval"
|
|
33
|
+
#: Shown to the person deciding (templates see `input`).
|
|
34
|
+
show: str | None = None
|
|
35
|
+
#: What ends the wait besides `whiletrue approve|reject`: see `parse_wake`.
|
|
36
|
+
wake: Any = Field(default="cli")
|
|
37
|
+
#: How often a watching gate re-checks (seconds, or a duration like `5s`).
|
|
38
|
+
poll: str | float = "2s"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _spec(config: ApproveConfig) -> WakeSpec:
|
|
42
|
+
return parse_wake(config.wake)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate(config: ApproveConfig, vctx: ValidateContext) -> list[ConfigIssue]:
|
|
46
|
+
try:
|
|
47
|
+
spec = _spec(config)
|
|
48
|
+
except ValueError as error:
|
|
49
|
+
return [ConfigIssue("error", str(error))]
|
|
50
|
+
issues = [
|
|
51
|
+
ConfigIssue("error", f'Unknown wake condition "{c.name}"')
|
|
52
|
+
for c in spec.conditions
|
|
53
|
+
if vctx.registry.get_provider("wake", c.name) is None
|
|
54
|
+
]
|
|
55
|
+
try:
|
|
56
|
+
parse_duration(config.poll)
|
|
57
|
+
except ValueError as error:
|
|
58
|
+
issues.append(ConfigIssue("error", f"`poll`: {error}"))
|
|
59
|
+
return issues
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _sleep(seconds: float, ctx: CompositeContext[ApproveConfig]) -> None:
|
|
63
|
+
waiter = asyncio.ensure_future(ctx.signal.wait())
|
|
64
|
+
try:
|
|
65
|
+
await asyncio.wait({waiter}, timeout=seconds)
|
|
66
|
+
finally:
|
|
67
|
+
waiter.cancel()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _park_for(ctx: CompositeContext[ApproveConfig], spec: WakeSpec) -> ParkRecord:
|
|
71
|
+
"""The Nth run of this gate within a lineage is the Nth park: replays find their old park."""
|
|
72
|
+
assert ctx.store is not None
|
|
73
|
+
parks = ctx.store.list_parks(ctx.item_id, node_path=ctx.node_id, run_id=ctx.lineage)
|
|
74
|
+
if ctx.attempt <= len(parks):
|
|
75
|
+
return parks[ctx.attempt - 1]
|
|
76
|
+
park = ctx.store.create_park(
|
|
77
|
+
run_id=ctx.lineage,
|
|
78
|
+
item_id=ctx.item_id,
|
|
79
|
+
node_path=ctx.node_id,
|
|
80
|
+
what=ctx.config.what,
|
|
81
|
+
wake=wake_to_dict(spec),
|
|
82
|
+
reason=f"waiting for {ctx.config.what}",
|
|
83
|
+
created_at=ctx.now(),
|
|
84
|
+
)
|
|
85
|
+
ctx.bus.emit(
|
|
86
|
+
"park.created",
|
|
87
|
+
ctx.run_id,
|
|
88
|
+
{"park": park.id, "node": ctx.node_id, "what": park.what},
|
|
89
|
+
item_id=ctx.item_id,
|
|
90
|
+
)
|
|
91
|
+
return park
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _item(ctx: CompositeContext[ApproveConfig]) -> dict[str, Any]:
|
|
95
|
+
item: dict[str, Any] | None = ctx.scope.get("item")
|
|
96
|
+
return item or {"id": ctx.item_id}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _run(
|
|
100
|
+
children: list[PipelineNode], input: PortValues, ctx: CompositeContext[ApproveConfig]
|
|
101
|
+
) -> Emit:
|
|
102
|
+
if ctx.store is None:
|
|
103
|
+
return Emit("fail", {"error": "human.approve needs a state store"})
|
|
104
|
+
store = ctx.store
|
|
105
|
+
spec = _spec(ctx.config)
|
|
106
|
+
park = _park_for(ctx, spec)
|
|
107
|
+
value = input.get("in")
|
|
108
|
+
item = _item(ctx)
|
|
109
|
+
shown = ctx.config.show # templates in config were rendered before the gate ran
|
|
110
|
+
poll = parse_duration(ctx.config.poll) / 1000
|
|
111
|
+
answered_attempts: set[int] = set()
|
|
112
|
+
|
|
113
|
+
while True:
|
|
114
|
+
park = next(p for p in store.list_parks(ctx.item_id) if p.id == park.id)
|
|
115
|
+
if park.decision is None:
|
|
116
|
+
woke = await evaluate_wake(
|
|
117
|
+
spec,
|
|
118
|
+
WakeContext(ctx.cwd, ctx.now, ctx.item_id, park, ctx.registry, item, value),
|
|
119
|
+
)
|
|
120
|
+
if woke is not None:
|
|
121
|
+
_record(ctx, park, woke)
|
|
122
|
+
continue
|
|
123
|
+
else:
|
|
124
|
+
return _outcome(park, value)
|
|
125
|
+
|
|
126
|
+
pending = await _unanswered(ctx, item, value)
|
|
127
|
+
fresh = [c for c in pending if c.id not in answered_attempts]
|
|
128
|
+
if fresh and children:
|
|
129
|
+
answered_attempts.update(c.id for c in fresh)
|
|
130
|
+
# New comments always get a live response, never a recorded one from an earlier cycle.
|
|
131
|
+
ctx.scope.set("no_replay", True)
|
|
132
|
+
cur = Emit("ok", {"comments": [_comment_dict(c) for c in fresh], "input": value})
|
|
133
|
+
for child in children:
|
|
134
|
+
cur = await ctx.run(child, {"in": cur.value})
|
|
135
|
+
if cur.port != "ok":
|
|
136
|
+
break
|
|
137
|
+
ctx.log("info", f"answered {len(fresh)} comment(s) on {ctx.item_id}", None)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not ctx.watch:
|
|
141
|
+
return Emit(
|
|
142
|
+
"needs_human",
|
|
143
|
+
{"park": park.id, "what": park.what, "show": shown, "waiting_for": "approval"},
|
|
144
|
+
)
|
|
145
|
+
if ctx.signal.aborted:
|
|
146
|
+
return Emit("fail", {"error": "cancelled"})
|
|
147
|
+
await _sleep(poll, ctx)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _comment_dict(comment: Any) -> dict[str, Any]:
|
|
151
|
+
return {
|
|
152
|
+
"id": comment.id,
|
|
153
|
+
"author": comment.author,
|
|
154
|
+
"body": comment.body,
|
|
155
|
+
"thread": comment.thread,
|
|
156
|
+
"url": comment.url,
|
|
157
|
+
"source": comment.source,
|
|
158
|
+
"kind": comment.kind,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _unanswered(ctx: CompositeContext[ApproveConfig], item: dict[str, Any], value: Any):
|
|
163
|
+
assert ctx.store is not None
|
|
164
|
+
await sync_comments(
|
|
165
|
+
ctx.registry, ctx.store, CommentsContext(ctx.cwd, ctx.now, ctx.item_id, item, value)
|
|
166
|
+
)
|
|
167
|
+
return ctx.store.list_comments(ctx.item_id, unanswered_only=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _record(ctx: CompositeContext[ApproveConfig], park: ParkRecord, woke: WakeResult) -> None:
|
|
171
|
+
assert ctx.store is not None
|
|
172
|
+
ctx.store.decide_park(
|
|
173
|
+
park.id, woke.decision, decided_by=woke.by, decided_at=ctx.now(), notes=woke.note
|
|
174
|
+
)
|
|
175
|
+
ctx.bus.emit(
|
|
176
|
+
"park.decided",
|
|
177
|
+
ctx.run_id,
|
|
178
|
+
{"park": park.id, "decision": woke.decision, "by": woke.by, "note": woke.note},
|
|
179
|
+
item_id=ctx.item_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _outcome(park: ParkRecord, value: Any) -> Emit:
|
|
184
|
+
"""Approval passes the input on. A rejection passes it on too, with the reviewer's note under
|
|
185
|
+
`rejection`, so whatever flows through the gate (the PR, say) survives a fix-and-review loop."""
|
|
186
|
+
carried: dict[str, Any] = (
|
|
187
|
+
{k: v for k, v in cast(dict[str, Any], value).items() if k != "rejection"}
|
|
188
|
+
if isinstance(value, dict)
|
|
189
|
+
else {"value": value}
|
|
190
|
+
)
|
|
191
|
+
if park.decision == "rejected":
|
|
192
|
+
carried["rejection"] = {"note": park.notes, "decided_by": park.decided_by}
|
|
193
|
+
return Emit("rejected", carried)
|
|
194
|
+
return Emit("ok", carried if isinstance(value, dict) else value)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#: Parks the item until a person approves or rejects (or a wake condition fires).
|
|
198
|
+
#: `do:` children run whenever new comments arrive, then the gate waits again.
|
|
199
|
+
approve_strategy: Strategy[ApproveConfig] = define_strategy(
|
|
200
|
+
Strategy(id="human.approve", config=ApproveConfig, run=_run, validate=_validate)
|
|
201
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from whiletrue.core import RunItem
|
|
8
|
+
from whiletrue.core.source import SourceError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JsonSource:
|
|
12
|
+
"""Items from a JSON file: a list of objects with an `id`, other fields, and `depends_on`."""
|
|
13
|
+
|
|
14
|
+
async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]:
|
|
15
|
+
path = config.get("path")
|
|
16
|
+
if not isinstance(path, str) or not path:
|
|
17
|
+
raise SourceError("`source.json` needs a `path`")
|
|
18
|
+
try:
|
|
19
|
+
data = json.loads((Path(cwd) / path).read_text(encoding="utf-8"))
|
|
20
|
+
except (OSError, ValueError) as error:
|
|
21
|
+
raise SourceError(f"Cannot read {path}: {error}") from error
|
|
22
|
+
if not isinstance(data, list):
|
|
23
|
+
raise SourceError(f"{path} must contain a JSON list of items")
|
|
24
|
+
items: list[RunItem] = []
|
|
25
|
+
for index, raw in enumerate(cast(list[Any], data)):
|
|
26
|
+
if not isinstance(raw, dict) or "id" not in raw:
|
|
27
|
+
raise SourceError(f"{path}: item {index} needs an `id`")
|
|
28
|
+
fields = {str(k): v for k, v in cast(dict[Any, Any], raw).items()}
|
|
29
|
+
depends = fields.pop("depends_on", [])
|
|
30
|
+
items.append(
|
|
31
|
+
RunItem(
|
|
32
|
+
str(fields.pop("id")), fields, [str(d) for d in cast(list[Any], depends or [])]
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return items
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from whiletrue.core import RunItem
|
|
8
|
+
from whiletrue.core.source import SourceError
|
|
9
|
+
|
|
10
|
+
_TASK = re.compile(r"^\s*[-*]\s+\[(?P<mark>[ xX])\]\s+(?P<text>.+?)\s*$")
|
|
11
|
+
_META = re.compile(r"\s*\{(?P<meta>[^{}]*)\}\s*$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _slug(text: str) -> str:
|
|
15
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "task"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MarkdownTasksSource:
|
|
19
|
+
"""Unchecked checklist lines of a markdown file are the items.
|
|
20
|
+
|
|
21
|
+
`- [ ] Add the login page {#login after=schema,api}`: the optional `{...}` gives the item an
|
|
22
|
+
id (default: a slug of the title) and the ids it waits for. Checked lines are done and skipped,
|
|
23
|
+
so ticking a box in the file removes the item on the next resume (the source wins).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]:
|
|
27
|
+
path = config.get("path")
|
|
28
|
+
if not isinstance(path, str) or not path:
|
|
29
|
+
raise SourceError("`source.markdown_tasks` needs a `path`")
|
|
30
|
+
try:
|
|
31
|
+
text = (Path(cwd) / path).read_text(encoding="utf-8")
|
|
32
|
+
except OSError as error:
|
|
33
|
+
raise SourceError(f"Cannot read {path}: {error}") from error
|
|
34
|
+
items: list[RunItem] = []
|
|
35
|
+
seen: set[str] = set()
|
|
36
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
37
|
+
match = _TASK.match(line)
|
|
38
|
+
if match is None or match["mark"] != " ":
|
|
39
|
+
continue
|
|
40
|
+
title = match["text"]
|
|
41
|
+
item_id, depends = "", []
|
|
42
|
+
meta = _META.search(title)
|
|
43
|
+
if meta is not None:
|
|
44
|
+
title = title[: meta.start()].strip()
|
|
45
|
+
for token in meta["meta"].split():
|
|
46
|
+
if token.startswith("#"):
|
|
47
|
+
item_id = token[1:]
|
|
48
|
+
elif token.startswith("after="):
|
|
49
|
+
depends = [d for d in token[6:].split(",") if d]
|
|
50
|
+
item_id = item_id or _slug(title)
|
|
51
|
+
if item_id in seen:
|
|
52
|
+
raise SourceError(f'{path}:{number}: duplicate task id "{item_id}"')
|
|
53
|
+
seen.add(item_id)
|
|
54
|
+
items.append(RunItem(item_id, {"title": title, "line": number}, depends))
|
|
55
|
+
return items
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import signal as signal_module
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from whiletrue.abort import AbortSignal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CommandResult:
|
|
15
|
+
#: Exit code; None if the process never started or was killed by a signal.
|
|
16
|
+
code: int | None
|
|
17
|
+
stdout: str
|
|
18
|
+
stderr: str
|
|
19
|
+
timed_out: bool = False
|
|
20
|
+
aborted: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def kill_process_group(pid: int) -> None:
|
|
24
|
+
"""SIGKILLs the whole group so grandchildren cannot keep our pipes open."""
|
|
25
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
26
|
+
os.killpg(pid, signal_module.SIGKILL)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def run_command(
|
|
30
|
+
command: str,
|
|
31
|
+
args: list[str],
|
|
32
|
+
*,
|
|
33
|
+
cwd: str,
|
|
34
|
+
env: dict[str, str] | None = None,
|
|
35
|
+
stdin: str | None = None,
|
|
36
|
+
signal: AbortSignal | None = None,
|
|
37
|
+
timeout_ms: float | None = None,
|
|
38
|
+
) -> CommandResult:
|
|
39
|
+
"""Runs a command to completion.
|
|
40
|
+
|
|
41
|
+
Never raises for non-zero exits; spawn errors are reported as `code=None` with the OS message
|
|
42
|
+
on stderr.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
proc = await asyncio.create_subprocess_exec(
|
|
46
|
+
command,
|
|
47
|
+
*args,
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
env={**os.environ, **(env or {})},
|
|
50
|
+
stdin=asyncio.subprocess.PIPE,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.PIPE,
|
|
53
|
+
start_new_session=True,
|
|
54
|
+
)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
return CommandResult(
|
|
57
|
+
code=None,
|
|
58
|
+
stdout="",
|
|
59
|
+
stderr=str(error),
|
|
60
|
+
aborted=signal.aborted if signal else False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
communicate = asyncio.ensure_future(proc.communicate((stdin or "").encode()))
|
|
64
|
+
abort_wait = asyncio.ensure_future(signal.wait()) if signal else None
|
|
65
|
+
waiting: set[asyncio.Future[Any]] = {communicate}
|
|
66
|
+
if abort_wait is not None:
|
|
67
|
+
waiting.add(abort_wait)
|
|
68
|
+
|
|
69
|
+
timed_out = False
|
|
70
|
+
aborted = False
|
|
71
|
+
try:
|
|
72
|
+
done, _ = await asyncio.wait(
|
|
73
|
+
waiting,
|
|
74
|
+
timeout=None if timeout_ms is None else timeout_ms / 1000,
|
|
75
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
76
|
+
)
|
|
77
|
+
if communicate not in done:
|
|
78
|
+
timed_out = not done
|
|
79
|
+
aborted = abort_wait is not None and abort_wait in done
|
|
80
|
+
kill_process_group(proc.pid)
|
|
81
|
+
stdout_b, stderr_b = await communicate
|
|
82
|
+
await proc.wait()
|
|
83
|
+
finally:
|
|
84
|
+
if abort_wait is not None:
|
|
85
|
+
abort_wait.cancel()
|
|
86
|
+
|
|
87
|
+
code = proc.returncode
|
|
88
|
+
return CommandResult(
|
|
89
|
+
code=None if code is None or code < 0 else code,
|
|
90
|
+
stdout=stdout_b.decode(errors="replace"),
|
|
91
|
+
stderr=stderr_b.decode(errors="replace"),
|
|
92
|
+
timed_out=timed_out,
|
|
93
|
+
aborted=aborted or (signal.aborted if signal else False),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def tail(text: str, n: int = 40) -> str:
|
|
98
|
+
"""Last `n` lines of text, for compact failure output."""
|
|
99
|
+
return "\n".join(text.rstrip().split("\n")[-n:])
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from whiletrue.core import (
|
|
6
|
+
ActionModule,
|
|
7
|
+
Emit,
|
|
8
|
+
EmptyConfig,
|
|
9
|
+
Ports,
|
|
10
|
+
PortSpec,
|
|
11
|
+
Scope,
|
|
12
|
+
StepContext,
|
|
13
|
+
define_action,
|
|
14
|
+
)
|
|
15
|
+
from whiletrue.core.module import PortValues
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SessionEndConfig(BaseModel):
|
|
19
|
+
#: The shared session to close (`item` is the one `session: item` uses).
|
|
20
|
+
name: str = "item"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sessions(scope: Scope) -> dict[str, str]:
|
|
24
|
+
sessions: dict[str, str] | None = scope.get("sessions")
|
|
25
|
+
return sessions if sessions is not None else {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _end(input: PortValues, ctx: StepContext[SessionEndConfig]) -> Emit:
|
|
29
|
+
_sessions(ctx.scope).pop(ctx.config.name, None)
|
|
30
|
+
return Emit("ok", input.get("in"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _reset(input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
|
|
34
|
+
_sessions(ctx.scope).clear()
|
|
35
|
+
return Emit("ok", input.get("in"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PORTS = Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")])
|
|
39
|
+
|
|
40
|
+
#: Closes a shared session: the next agent that asks for it starts a new conversation.
|
|
41
|
+
session_end_module: ActionModule[SessionEndConfig] = define_action(
|
|
42
|
+
ActionModule(
|
|
43
|
+
id="session.end",
|
|
44
|
+
config=SessionEndConfig,
|
|
45
|
+
ports=_PORTS,
|
|
46
|
+
effects="deterministic",
|
|
47
|
+
cacheable=False,
|
|
48
|
+
run=_end,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Drops every shared session of the item, so later agents start with a clean context.
|
|
53
|
+
context_reset_module: ActionModule[EmptyConfig] = define_action(
|
|
54
|
+
ActionModule(
|
|
55
|
+
id="context.reset",
|
|
56
|
+
config=EmptyConfig,
|
|
57
|
+
ports=_PORTS,
|
|
58
|
+
effects="deterministic",
|
|
59
|
+
cacheable=False,
|
|
60
|
+
run=_reset,
|
|
61
|
+
)
|
|
62
|
+
)
|
whiletrue/basics/set_.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from whiletrue.core import ActionModule, Emit, Ports, PortSpec, StepContext, define_action
|
|
8
|
+
from whiletrue.core.module import PortValues
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SetConfig(BaseModel):
|
|
12
|
+
#: Any JSON value; `{{...}}` templates in strings are rendered before the step runs.
|
|
13
|
+
value: Any = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _run(_input: PortValues, ctx: StepContext[SetConfig]) -> Emit:
|
|
17
|
+
return Emit("ok", ctx.config.value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Emits a constant (after templating). Useful for markers in branches and for tests.
|
|
21
|
+
set_module: ActionModule[SetConfig] = define_action(
|
|
22
|
+
ActionModule(
|
|
23
|
+
id="set",
|
|
24
|
+
config=SetConfig,
|
|
25
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
26
|
+
effects="pure",
|
|
27
|
+
run=_run,
|
|
28
|
+
)
|
|
29
|
+
)
|