cilydian-studio 0.1.0__tar.gz
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.
- cilydian_studio-0.1.0/LICENSE +21 -0
- cilydian_studio-0.1.0/PKG-INFO +64 -0
- cilydian_studio-0.1.0/README.md +48 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/PKG-INFO +64 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/SOURCES.txt +12 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/dependency_links.txt +1 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/entry_points.txt +2 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/requires.txt +3 -0
- cilydian_studio-0.1.0/cilydian_studio.egg-info/top_level.txt +1 -0
- cilydian_studio-0.1.0/pyproject.toml +28 -0
- cilydian_studio-0.1.0/setup.cfg +4 -0
- cilydian_studio-0.1.0/studio/__init__.py +1 -0
- cilydian_studio-0.1.0/studio/app.py +122 -0
- cilydian_studio-0.1.0/studio/session.py +150 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sleepti3ht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cilydian-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Textual TUI client for CiLydian: human-in-the-loop agentic CI
|
|
5
|
+
Author-email: sleepti3ht <sleepti3ht@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sleepti3ht/cilydian-studio
|
|
8
|
+
Project-URL: Repository, https://github.com/sleepti3ht/cilydian-studio
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: cilydian>=0.2.1
|
|
13
|
+
Requires-Dist: textual>=0.80
|
|
14
|
+
Requires-Dist: langgraph-checkpoint-sqlite>=2.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# cilydian-studio
|
|
18
|
+
|
|
19
|
+
Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install cilydian-studio
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The TUI will pause at interrupt points:
|
|
34
|
+
- **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
|
|
35
|
+
- **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Action |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `continue` | Proceed to next stage |
|
|
42
|
+
| `edit <text>` | Replace spec with `<text>` |
|
|
43
|
+
| `reject` | Discard spec, regenerate |
|
|
44
|
+
| `accept` | Accept failing gates (escalation only) |
|
|
45
|
+
| `retry <feedback>` | Send feedback to coder, retry (escalation only) |
|
|
46
|
+
| `abort` | Stop execution (escalation only) |
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
Studio is a thin client over CiLydian core:
|
|
51
|
+
- **cilydian** (PyPI package): graph, gates, memory
|
|
52
|
+
- **cilydian-studio**: Textual TUI + interrupt/resume loop
|
|
53
|
+
|
|
54
|
+
Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Python >= 3.12
|
|
59
|
+
- cilydian >= 0.2.0 (installed automatically)
|
|
60
|
+
- Local Ollama or cloud API keys (same as CiLydian)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# cilydian-studio
|
|
2
|
+
|
|
3
|
+
Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cilydian-studio
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The TUI will pause at interrupt points:
|
|
18
|
+
- **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
|
|
19
|
+
- **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
| Command | Action |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `continue` | Proceed to next stage |
|
|
26
|
+
| `edit <text>` | Replace spec with `<text>` |
|
|
27
|
+
| `reject` | Discard spec, regenerate |
|
|
28
|
+
| `accept` | Accept failing gates (escalation only) |
|
|
29
|
+
| `retry <feedback>` | Send feedback to coder, retry (escalation only) |
|
|
30
|
+
| `abort` | Stop execution (escalation only) |
|
|
31
|
+
|
|
32
|
+
## Architecture
|
|
33
|
+
|
|
34
|
+
Studio is a thin client over CiLydian core:
|
|
35
|
+
- **cilydian** (PyPI package): graph, gates, memory
|
|
36
|
+
- **cilydian-studio**: Textual TUI + interrupt/resume loop
|
|
37
|
+
|
|
38
|
+
Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python >= 3.12
|
|
43
|
+
- cilydian >= 0.2.0 (installed automatically)
|
|
44
|
+
- Local Ollama or cloud API keys (same as CiLydian)
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cilydian-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Textual TUI client for CiLydian: human-in-the-loop agentic CI
|
|
5
|
+
Author-email: sleepti3ht <sleepti3ht@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sleepti3ht/cilydian-studio
|
|
8
|
+
Project-URL: Repository, https://github.com/sleepti3ht/cilydian-studio
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: cilydian>=0.2.1
|
|
13
|
+
Requires-Dist: textual>=0.80
|
|
14
|
+
Requires-Dist: langgraph-checkpoint-sqlite>=2.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# cilydian-studio
|
|
18
|
+
|
|
19
|
+
Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install cilydian-studio
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The TUI will pause at interrupt points:
|
|
34
|
+
- **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
|
|
35
|
+
- **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Action |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `continue` | Proceed to next stage |
|
|
42
|
+
| `edit <text>` | Replace spec with `<text>` |
|
|
43
|
+
| `reject` | Discard spec, regenerate |
|
|
44
|
+
| `accept` | Accept failing gates (escalation only) |
|
|
45
|
+
| `retry <feedback>` | Send feedback to coder, retry (escalation only) |
|
|
46
|
+
| `abort` | Stop execution (escalation only) |
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
Studio is a thin client over CiLydian core:
|
|
51
|
+
- **cilydian** (PyPI package): graph, gates, memory
|
|
52
|
+
- **cilydian-studio**: Textual TUI + interrupt/resume loop
|
|
53
|
+
|
|
54
|
+
Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Python >= 3.12
|
|
59
|
+
- cilydian >= 0.2.0 (installed automatically)
|
|
60
|
+
- Local Ollama or cloud API keys (same as CiLydian)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
cilydian_studio.egg-info/PKG-INFO
|
|
5
|
+
cilydian_studio.egg-info/SOURCES.txt
|
|
6
|
+
cilydian_studio.egg-info/dependency_links.txt
|
|
7
|
+
cilydian_studio.egg-info/entry_points.txt
|
|
8
|
+
cilydian_studio.egg-info/requires.txt
|
|
9
|
+
cilydian_studio.egg-info/top_level.txt
|
|
10
|
+
studio/__init__.py
|
|
11
|
+
studio/app.py
|
|
12
|
+
studio/session.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
studio
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cilydian-studio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Textual TUI client for CiLydian: human-in-the-loop agentic CI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [{name = "sleepti3ht", email = "sleepti3ht@gmail.com"}]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cilydian>=0.2.1",
|
|
16
|
+
"textual>=0.80",
|
|
17
|
+
"langgraph-checkpoint-sqlite>=2.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
cilydian-studio = "studio.app:main_entry"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/sleepti3ht/cilydian-studio"
|
|
25
|
+
Repository = "https://github.com/sleepti3ht/cilydian-studio"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["studio"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CiLydian Studio: Textual TUI client for human-in-the-loop agentic CI."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Minimal Textual TUI: chat with the pipeline at interrupt points."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.widgets import Footer, Header, Input, RichLog
|
|
7
|
+
|
|
8
|
+
from studio.session import StudioSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StudioApp(App):
|
|
12
|
+
"""Textual TUI for CiLydian human-in-the-loop sessions."""
|
|
13
|
+
|
|
14
|
+
CSS = """
|
|
15
|
+
RichLog {
|
|
16
|
+
height: 1fr;
|
|
17
|
+
border: solid green;
|
|
18
|
+
}
|
|
19
|
+
Input {
|
|
20
|
+
dock: bottom;
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, idea: str, run_id: str | None = None, config_path: str = "config.toml"
|
|
26
|
+
):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.session = StudioSession(run_id=run_id, idea=idea, config_path=config_path)
|
|
29
|
+
self.title = f"CiLydian Studio: {idea[:40]}"
|
|
30
|
+
self._session_finished = False
|
|
31
|
+
|
|
32
|
+
def compose(self) -> ComposeResult:
|
|
33
|
+
yield Header()
|
|
34
|
+
self.rich_log = RichLog(markup=True, max_lines=5000)
|
|
35
|
+
yield self.rich_log
|
|
36
|
+
yield Input(
|
|
37
|
+
placeholder="continue | edit <spec> | retry <feedback> | accept | reject | abort"
|
|
38
|
+
)
|
|
39
|
+
yield Footer()
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
"""Start session and poll events."""
|
|
43
|
+
try:
|
|
44
|
+
self.session.start()
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
self.rich_log.write(f"[red]Failed to start session:[/red] {exc}")
|
|
47
|
+
self._session_finished = True
|
|
48
|
+
return
|
|
49
|
+
self.set_interval(0.2, self._poll)
|
|
50
|
+
self.rich_log.write("[blue]Session started. Waiting for interrupt...[/blue]")
|
|
51
|
+
|
|
52
|
+
def _poll(self) -> None:
|
|
53
|
+
"""Check for events from graph worker."""
|
|
54
|
+
if self._session_finished:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
while not self.session.events.empty():
|
|
58
|
+
ev = self.session.events.get()
|
|
59
|
+
if ev["type"] == "interrupt":
|
|
60
|
+
payload = ev["payload"]
|
|
61
|
+
stage = payload.get("stage", "?").upper()
|
|
62
|
+
self.rich_log.write(f"[yellow]== {stage} awaits decision ==[/yellow]")
|
|
63
|
+
|
|
64
|
+
if "spec" in payload:
|
|
65
|
+
self.rich_log.write(f"[dim]SPEC:[/dim]\n{payload['spec'][:1000]}")
|
|
66
|
+
if "verdict" in payload:
|
|
67
|
+
self.rich_log.write(f"[dim]VERDICT:[/dim] {payload['verdict']}")
|
|
68
|
+
|
|
69
|
+
actions = payload.get("actions", [])
|
|
70
|
+
self.rich_log.write(f"[cyan]actions: {actions}[/cyan]")
|
|
71
|
+
|
|
72
|
+
elif ev["type"] == "done":
|
|
73
|
+
self.rich_log.write(f"[green]DONE[/green] {ev['verdict']}")
|
|
74
|
+
self.rich_log.write(
|
|
75
|
+
"[dim]Session finished. Press Ctrl+C to exit.[/dim]"
|
|
76
|
+
)
|
|
77
|
+
self._session_finished = True
|
|
78
|
+
|
|
79
|
+
elif ev["type"] == "error":
|
|
80
|
+
self.rich_log.write(f"[red]ERROR:[/red] {ev['message']}")
|
|
81
|
+
self.rich_log.write("[dim]Session failed. Press Ctrl+C to exit.[/dim]")
|
|
82
|
+
self._session_finished = True
|
|
83
|
+
|
|
84
|
+
def on_input_submitted(self, msg: Input.Submitted) -> None:
|
|
85
|
+
self.rich_log.write(f"[blue]> {msg.value}[/blue]")
|
|
86
|
+
if self._session_finished:
|
|
87
|
+
self.rich_log.write("[yellow]session is over; command ignored[/yellow]")
|
|
88
|
+
elif not self.session.awaiting_decision.is_set():
|
|
89
|
+
self.rich_log.write("[yellow]no pending decision; command ignored[/yellow]")
|
|
90
|
+
else:
|
|
91
|
+
self.session.send(msg.value)
|
|
92
|
+
msg.input.value = ""
|
|
93
|
+
|
|
94
|
+
def on_exit(self) -> None:
|
|
95
|
+
"""Called when TUI closes."""
|
|
96
|
+
self.session.stop()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main_entry() -> None:
|
|
100
|
+
"""CLI entry point: cilydian-studio [--config PATH] [--run-id ID] "idea" """
|
|
101
|
+
parser = argparse.ArgumentParser(
|
|
102
|
+
description="Textual TUI for CiLydian human-in-the-loop sessions"
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument("idea", nargs="+", help="one-line task description")
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--config",
|
|
107
|
+
default="config.toml",
|
|
108
|
+
help="path to role/model config (default: config.toml in CWD)",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--run-id",
|
|
112
|
+
default=None,
|
|
113
|
+
help="artifact dir under runs/ (default: studio-<uuid>)",
|
|
114
|
+
)
|
|
115
|
+
args = parser.parse_args()
|
|
116
|
+
StudioApp(
|
|
117
|
+
idea=" ".join(args.idea), config_path=args.config, run_id=args.run_id
|
|
118
|
+
).run()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main_entry()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Interrupt/resume session bridge: worker thread runs the graph, TUI talks to human."""
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import cilydian.pipeline as core
|
|
10
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
11
|
+
from langgraph.types import Command
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StudioSession:
|
|
15
|
+
"""Wraps CiLydian graph with interrupt/resume for human-in-the-loop."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, idea: str, run_id: str | None = None, config_path: str = "config.toml"
|
|
19
|
+
):
|
|
20
|
+
self.run_id = run_id or f"studio-{uuid.uuid4().hex[:8]}"
|
|
21
|
+
self.idea = idea
|
|
22
|
+
self.events: queue.Queue = queue.Queue()
|
|
23
|
+
self.commands: queue.Queue = queue.Queue()
|
|
24
|
+
self.awaiting_decision = threading.Event()
|
|
25
|
+
self._worker: threading.Thread | None = None
|
|
26
|
+
self._stop_requested = threading.Event()
|
|
27
|
+
|
|
28
|
+
# Initialize core pipeline
|
|
29
|
+
core.init_from_config(config_path, hitl=True)
|
|
30
|
+
core.preflight()
|
|
31
|
+
core.begin_run(self.run_id)
|
|
32
|
+
|
|
33
|
+
# Checkpointer for interrupt/resume
|
|
34
|
+
self._db_conn = sqlite3.connect(
|
|
35
|
+
f"studio-{self.run_id}.db", check_same_thread=False
|
|
36
|
+
)
|
|
37
|
+
self.checkpointer = SqliteSaver(self._db_conn)
|
|
38
|
+
self.graph = core.build_app(checkpointer=self.checkpointer)
|
|
39
|
+
self.thread_config = {"configurable": {"thread_id": self.run_id}}
|
|
40
|
+
|
|
41
|
+
def _log(self, msg: str) -> None:
|
|
42
|
+
"""Write directly to a file, bypassing Textual's stdout capture."""
|
|
43
|
+
with open("force_debug.log", "a", encoding="utf-8") as f:
|
|
44
|
+
f.write(f"{msg}\n")
|
|
45
|
+
|
|
46
|
+
def start(self) -> None:
|
|
47
|
+
"""Start graph execution in worker thread."""
|
|
48
|
+
self._log("--- START CALLED ---")
|
|
49
|
+
self._spawn(None)
|
|
50
|
+
|
|
51
|
+
def _spawn(self, resume: Any) -> None:
|
|
52
|
+
self._worker = threading.Thread(target=self._run, args=(resume,), daemon=True)
|
|
53
|
+
self._worker.start()
|
|
54
|
+
|
|
55
|
+
def is_alive(self) -> bool:
|
|
56
|
+
return self._worker is not None and self._worker.is_alive()
|
|
57
|
+
|
|
58
|
+
def stop(self) -> None:
|
|
59
|
+
"""Signal the graph to stop and close the DB connection."""
|
|
60
|
+
self._log("--- STOP CALLED ---")
|
|
61
|
+
self._stop_requested.set()
|
|
62
|
+
self.commands.put(None) # Unblock get()
|
|
63
|
+
if hasattr(self, "_db_conn"):
|
|
64
|
+
try:
|
|
65
|
+
self._db_conn.close()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def _initial_state(self) -> dict:
|
|
70
|
+
return {
|
|
71
|
+
"idea": self.idea,
|
|
72
|
+
"spec": "",
|
|
73
|
+
"spec_path": "",
|
|
74
|
+
"code": "",
|
|
75
|
+
"verdict": {},
|
|
76
|
+
"iteration": 0,
|
|
77
|
+
"human_action": "",
|
|
78
|
+
"code_hash": "",
|
|
79
|
+
"prev_code_hash": "",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def _run(self, resume: Any) -> None:
|
|
83
|
+
"""Run graph; catch interrupts, wait for human decision, resume."""
|
|
84
|
+
self._log(f"--- RUN STARTED, resume={resume} ---")
|
|
85
|
+
try:
|
|
86
|
+
if resume is not None:
|
|
87
|
+
payload = Command(resume=resume)
|
|
88
|
+
else:
|
|
89
|
+
payload = self._initial_state()
|
|
90
|
+
|
|
91
|
+
self._log("Calling graph.invoke()...")
|
|
92
|
+
result = self.graph.invoke(payload, config=self.thread_config)
|
|
93
|
+
self._log("graph.invoke() returned.")
|
|
94
|
+
|
|
95
|
+
state = self.graph.get_state(self.thread_config)
|
|
96
|
+
if state.next: # paused at an interrupt point
|
|
97
|
+
self._log("Graph paused at interrupt.")
|
|
98
|
+
value = {}
|
|
99
|
+
if state.tasks and state.tasks[0].interrupts:
|
|
100
|
+
value = state.tasks[0].interrupts[0].value
|
|
101
|
+
|
|
102
|
+
self.events.put({"type": "interrupt", "payload": value})
|
|
103
|
+
self.awaiting_decision.set()
|
|
104
|
+
|
|
105
|
+
# Wait for command without timing out the user
|
|
106
|
+
while not self._stop_requested.is_set():
|
|
107
|
+
try:
|
|
108
|
+
answer = self.commands.get(timeout=0.5)
|
|
109
|
+
if answer is None: # Stop signal
|
|
110
|
+
self._log("Received None command (stop). Exiting.")
|
|
111
|
+
return
|
|
112
|
+
self._log(f"Received command: {answer}")
|
|
113
|
+
self.awaiting_decision.clear()
|
|
114
|
+
self._spawn(answer)
|
|
115
|
+
return
|
|
116
|
+
except queue.Empty:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
self._log("Stop requested while waiting. Exiting.")
|
|
120
|
+
return
|
|
121
|
+
else:
|
|
122
|
+
self._log("Graph finished.")
|
|
123
|
+
verdict = (
|
|
124
|
+
(result or {}).get("verdict", {})
|
|
125
|
+
if isinstance(result, dict)
|
|
126
|
+
else {}
|
|
127
|
+
)
|
|
128
|
+
self.events.put({"type": "done", "verdict": verdict})
|
|
129
|
+
|
|
130
|
+
except Exception as exc: # noqa: BLE001
|
|
131
|
+
self._log(f"EXCEPTION in _run: {type(exc).__name__}: {exc}")
|
|
132
|
+
self.events.put(
|
|
133
|
+
{"type": "error", "message": f"{type(exc).__name__}: {exc}"}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def send(self, raw: str) -> None:
|
|
137
|
+
"""Parse human input and send to graph."""
|
|
138
|
+
if not self.awaiting_decision.is_set():
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
raw = raw.strip()
|
|
142
|
+
if raw.startswith("edit "):
|
|
143
|
+
value = {"action": "edit", "spec": raw[5:]}
|
|
144
|
+
elif raw.startswith("retry"):
|
|
145
|
+
value = {"action": "retry", "feedback": raw[5:].strip()}
|
|
146
|
+
elif raw in ("reject", "abort", "accept", "continue"):
|
|
147
|
+
value = {"action": raw}
|
|
148
|
+
else:
|
|
149
|
+
value = {"action": "continue"}
|
|
150
|
+
self.commands.put(value)
|