r2flow-designer 0.3.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.
- r2flow_designer/__init__.py +1 -0
- r2flow_designer/__main__.py +41 -0
- r2flow_designer/capture.py +32 -0
- r2flow_designer/debugger.py +494 -0
- r2flow_designer/publish.py +218 -0
- r2flow_designer/record.py +107 -0
- r2flow_designer/static/assets/index-15BQLYES.css +1 -0
- r2flow_designer/static/assets/index-BATKGBud.js +15 -0
- r2flow_designer/static/favicon.svg +1 -0
- r2flow_designer/static/icons.svg +24 -0
- r2flow_designer/static/index.html +13 -0
- r2flow_designer/tui.py +252 -0
- r2flow_designer/web.py +467 -0
- r2flow_designer-0.3.0.dist-info/METADATA +147 -0
- r2flow_designer-0.3.0.dist-info/RECORD +18 -0
- r2flow_designer-0.3.0.dist-info/WHEEL +4 -0
- r2flow_designer-0.3.0.dist-info/entry_points.txt +2 -0
- r2flow_designer-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""R2Flow designer - minimal Textual TUI for editing flow graphs (spike)."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Run the R2Flow web designer: r2flow-designer [flow.json]."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import uvicorn
|
|
12
|
+
|
|
13
|
+
from r2flow_designer.web import create_app
|
|
14
|
+
|
|
15
|
+
DEFAULT_PORT = 8756
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
19
|
+
"""Serve the designer UI and flow API for a single flow file."""
|
|
20
|
+
parser = argparse.ArgumentParser(prog="r2flow-designer", description=__doc__)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"flow",
|
|
23
|
+
nargs="?",
|
|
24
|
+
default="flow.json",
|
|
25
|
+
help="main flow file of the project (default: flow.json)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
28
|
+
parser.add_argument("--no-browser", action="store_true", help="do not open browser")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
flow_path = Path(args.flow).resolve()
|
|
32
|
+
app = create_app(flow_path)
|
|
33
|
+
url = f"http://127.0.0.1:{args.port}"
|
|
34
|
+
print(f"R2Flow designer: {url} (flow: {flow_path})")
|
|
35
|
+
if not args.no_browser:
|
|
36
|
+
threading.Timer(1.0, webbrowser.open, [url]).start()
|
|
37
|
+
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Interactive selector capture for the designer UI.
|
|
2
|
+
|
|
3
|
+
Wraps the engine's single-shot capture (:func:`capture_once`): the user
|
|
4
|
+
hovers an element and presses CTRL, ESC cancels. ``capture_once`` blocks
|
|
5
|
+
(and needs the real desktop), so it is always called from a worker thread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from r2flow.windows.tools.selector_capture import CaptureCancelled, capture_once
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CaptureError(RuntimeError):
|
|
16
|
+
"""Raised when a capture is cancelled or the backend is unavailable."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def capture_selector() -> dict[str, Any]:
|
|
20
|
+
"""Block for one interactive capture and return its ranked selector."""
|
|
21
|
+
try:
|
|
22
|
+
captured = capture_once()
|
|
23
|
+
except CaptureCancelled as exc:
|
|
24
|
+
raise CaptureError(str(exc)) from exc
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise CaptureError(f"selector capture is unavailable: {exc}") from exc
|
|
27
|
+
return {
|
|
28
|
+
"selector": captured.selector,
|
|
29
|
+
"full_path": captured.full_path,
|
|
30
|
+
"confidence": captured.confidence,
|
|
31
|
+
"warnings": list(captured.warnings),
|
|
32
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Step debugger for v2 flow documents.
|
|
2
|
+
|
|
3
|
+
Interactive layer on top of the engine-side :mod:`r2flow.flow` executor:
|
|
4
|
+
runs a flow graph node by node with pause/resume/step control, breakpoints,
|
|
5
|
+
a variable scope shared with a REPL evaluator, and a structured event log.
|
|
6
|
+
All node execution (tool calls, interpolation, conditions, loops, ``set``)
|
|
7
|
+
is delegated to :class:`r2flow.flow.FlowRunner` — the debugger only owns
|
|
8
|
+
the control flow: gates, retries, breakpoints and the REPL.
|
|
9
|
+
|
|
10
|
+
Variables:
|
|
11
|
+
* a ``set`` node creates/overwrites a variable with a typed literal
|
|
12
|
+
(type: auto | string | number | bool | json); the value may
|
|
13
|
+
reference other variables (``$name``, ``$app.pid``)
|
|
14
|
+
* ``save_as`` on a tool node stores the tool result
|
|
15
|
+
* ``$name`` inside tool config strings interpolates from the scope;
|
|
16
|
+
paths drill into saved results (``$app.pid``, ``$rows[0].name``) —
|
|
17
|
+
a string that is a single reference keeps the value's type
|
|
18
|
+
* ``if`` / ``loop`` conditions accept variables too: ``var`` may be a
|
|
19
|
+
dotted path (``app.pid``) and ``value`` interpolates ``$refs``
|
|
20
|
+
* the REPL can read variables and assign new ones between steps
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
import asyncio
|
|
27
|
+
import time
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from r2flow.flow import (
|
|
31
|
+
FLOW_VERSION,
|
|
32
|
+
FlowError,
|
|
33
|
+
FlowRunner,
|
|
34
|
+
jsonable,
|
|
35
|
+
short,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from r2flow.core.registry import ToolRegistry
|
|
40
|
+
|
|
41
|
+
_MAX_LOG = 500
|
|
42
|
+
_MAX_REPL = 100
|
|
43
|
+
|
|
44
|
+
_LOG_LIMIT = 400
|
|
45
|
+
_MAX_EXPR_LEN = 2000
|
|
46
|
+
_MAX_EXPR_NODES = 200
|
|
47
|
+
_MAX_POW_EXP = 30
|
|
48
|
+
_MAX_POW_BASE_ABS = 10**6
|
|
49
|
+
_MAX_MUL_REPEAT = 10_000
|
|
50
|
+
_MAX_STATE_VAR_BYTES = 10_000
|
|
51
|
+
_MAX_STATE_TOTAL_BYTES = 200_000
|
|
52
|
+
|
|
53
|
+
#: Mutating container methods are blocked in the REPL: the scope is shared
|
|
54
|
+
#: with the running debug session, so `data.clear()` would corrupt it.
|
|
55
|
+
_BLOCKED_METHODS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"clear",
|
|
58
|
+
"pop",
|
|
59
|
+
"popitem",
|
|
60
|
+
"update",
|
|
61
|
+
"setdefault",
|
|
62
|
+
"append",
|
|
63
|
+
"extend",
|
|
64
|
+
"insert",
|
|
65
|
+
"remove",
|
|
66
|
+
"sort",
|
|
67
|
+
"reverse",
|
|
68
|
+
"discard",
|
|
69
|
+
"add",
|
|
70
|
+
"difference_update",
|
|
71
|
+
"intersection_update",
|
|
72
|
+
"symmetric_difference_update",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DebugError(FlowError):
|
|
78
|
+
"""Raised for debugger misuse or unsupported flow constructs."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _jsonable(value: Any) -> Any:
|
|
82
|
+
return jsonable(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _truncate_variables(variables: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
"""Bound /api/debug/state payload: huge tables no longer OOM the poll loop."""
|
|
87
|
+
import json as _json
|
|
88
|
+
|
|
89
|
+
out: dict[str, Any] = {}
|
|
90
|
+
total = 0
|
|
91
|
+
for key, value in variables.items():
|
|
92
|
+
rendered: Any = _jsonable(value)
|
|
93
|
+
try:
|
|
94
|
+
size = len(_json.dumps(rendered, ensure_ascii=False, default=str))
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
size = _MAX_STATE_VAR_BYTES + 1
|
|
97
|
+
if size > _MAX_STATE_VAR_BYTES:
|
|
98
|
+
preview = short(repr(rendered), _MAX_STATE_VAR_BYTES)
|
|
99
|
+
rendered = {
|
|
100
|
+
"__truncated__": True,
|
|
101
|
+
"type": type(value).__name__,
|
|
102
|
+
"preview": preview,
|
|
103
|
+
}
|
|
104
|
+
size = len(preview)
|
|
105
|
+
total += size
|
|
106
|
+
if total > _MAX_STATE_TOTAL_BYTES:
|
|
107
|
+
out[key] = {"__truncated__": True, "type": "…", "preview": "… state cap …"}
|
|
108
|
+
continue
|
|
109
|
+
out[key] = rendered
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -- REPL sandbox (debugger-only: the flow runner never evaluates Python) --
|
|
114
|
+
|
|
115
|
+
import operator # noqa: E402
|
|
116
|
+
|
|
117
|
+
_SAFE_FUNCS: dict[str, Any] = {
|
|
118
|
+
"len": len,
|
|
119
|
+
"str": str,
|
|
120
|
+
"repr": repr,
|
|
121
|
+
"int": int,
|
|
122
|
+
"float": float,
|
|
123
|
+
"bool": bool,
|
|
124
|
+
"round": round,
|
|
125
|
+
"abs": abs,
|
|
126
|
+
"sum": sum,
|
|
127
|
+
"min": min,
|
|
128
|
+
"max": max,
|
|
129
|
+
"sorted": sorted,
|
|
130
|
+
"list": list,
|
|
131
|
+
"tuple": tuple,
|
|
132
|
+
"set": set,
|
|
133
|
+
"dict": dict,
|
|
134
|
+
}
|
|
135
|
+
_BIN_OPS: dict[type[ast.operator], Any] = {
|
|
136
|
+
ast.Add: operator.add,
|
|
137
|
+
ast.Sub: operator.sub,
|
|
138
|
+
ast.Mult: operator.mul,
|
|
139
|
+
ast.Div: operator.truediv,
|
|
140
|
+
ast.FloorDiv: operator.floordiv,
|
|
141
|
+
ast.Mod: operator.mod,
|
|
142
|
+
ast.Pow: operator.pow,
|
|
143
|
+
}
|
|
144
|
+
_CMP_OPS: dict[type[ast.cmpop], Any] = {
|
|
145
|
+
ast.Eq: operator.eq,
|
|
146
|
+
ast.NotEq: operator.ne,
|
|
147
|
+
ast.Lt: operator.lt,
|
|
148
|
+
ast.LtE: operator.le,
|
|
149
|
+
ast.Gt: operator.gt,
|
|
150
|
+
ast.GtE: operator.ge,
|
|
151
|
+
ast.In: lambda a, b: a in b,
|
|
152
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
153
|
+
ast.Is: operator.is_,
|
|
154
|
+
ast.IsNot: operator.is_not,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _eval_node(node: ast.expr, variables: dict[str, Any]) -> Any:
|
|
159
|
+
if isinstance(node, ast.Constant):
|
|
160
|
+
if isinstance(node.value, str) and len(node.value) > _MAX_EXPR_LEN:
|
|
161
|
+
raise DebugError("string literal too large")
|
|
162
|
+
return node.value
|
|
163
|
+
if isinstance(node, ast.Name):
|
|
164
|
+
if node.id in variables:
|
|
165
|
+
return variables[node.id]
|
|
166
|
+
if node.id in _SAFE_FUNCS:
|
|
167
|
+
return _SAFE_FUNCS[node.id]
|
|
168
|
+
raise DebugError(f"name {node.id!r} is not defined")
|
|
169
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
|
|
170
|
+
left = _eval_node(node.left, variables)
|
|
171
|
+
right = _eval_node(node.right, variables)
|
|
172
|
+
if isinstance(node.op, ast.Pow):
|
|
173
|
+
try:
|
|
174
|
+
if isinstance(right, (int, float)):
|
|
175
|
+
r = abs(right)
|
|
176
|
+
lb = abs(left) if isinstance(left, (int, float)) else 0
|
|
177
|
+
# Allows 2**64 and 10**100, blocks 10**10**10-class bombs.
|
|
178
|
+
if (
|
|
179
|
+
r > 1000
|
|
180
|
+
or (lb > _MAX_POW_BASE_ABS and r > 6)
|
|
181
|
+
or (lb >= 10 and r > 100)
|
|
182
|
+
):
|
|
183
|
+
raise DebugError("pow operands too large (DoS guard)")
|
|
184
|
+
except DebugError:
|
|
185
|
+
raise
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
raise DebugError("invalid pow operands") from exc
|
|
188
|
+
if isinstance(node.op, ast.Mult):
|
|
189
|
+
# Guard `"x" * 10**9` / `[..] * 10**9` memory bombs.
|
|
190
|
+
if isinstance(left, (str, list, tuple)) and isinstance(right, int):
|
|
191
|
+
if abs(right) > _MAX_MUL_REPEAT:
|
|
192
|
+
raise DebugError("repetition too large (DoS guard)")
|
|
193
|
+
elif (
|
|
194
|
+
isinstance(right, (str, list, tuple))
|
|
195
|
+
and isinstance(left, int)
|
|
196
|
+
and abs(left) > _MAX_MUL_REPEAT
|
|
197
|
+
):
|
|
198
|
+
raise DebugError("repetition too large (DoS guard)")
|
|
199
|
+
return _BIN_OPS[type(node.op)](left, right)
|
|
200
|
+
if isinstance(node, ast.UnaryOp):
|
|
201
|
+
value = _eval_node(node.operand, variables)
|
|
202
|
+
if isinstance(node.op, ast.Not):
|
|
203
|
+
return not value
|
|
204
|
+
if isinstance(node.op, ast.USub):
|
|
205
|
+
return -value
|
|
206
|
+
if isinstance(node.op, ast.UAdd):
|
|
207
|
+
return +value
|
|
208
|
+
if isinstance(node, ast.BoolOp):
|
|
209
|
+
result: Any = isinstance(node.op, ast.And)
|
|
210
|
+
for sub in node.values:
|
|
211
|
+
result = _eval_node(sub, variables)
|
|
212
|
+
if isinstance(node.op, ast.And) and not result:
|
|
213
|
+
return result
|
|
214
|
+
if isinstance(node.op, ast.Or) and result:
|
|
215
|
+
return result
|
|
216
|
+
return result
|
|
217
|
+
if isinstance(node, ast.Compare):
|
|
218
|
+
left = _eval_node(node.left, variables)
|
|
219
|
+
for op, comparator in zip(node.ops, node.comparators, strict=True):
|
|
220
|
+
right = _eval_node(comparator, variables)
|
|
221
|
+
fn = _CMP_OPS.get(type(op))
|
|
222
|
+
if fn is None:
|
|
223
|
+
raise DebugError(f"unsupported comparison {type(op).__name__}")
|
|
224
|
+
if not fn(left, right):
|
|
225
|
+
return False
|
|
226
|
+
left = right
|
|
227
|
+
return True
|
|
228
|
+
if isinstance(node, ast.Subscript):
|
|
229
|
+
obj = _eval_node(node.value, variables)
|
|
230
|
+
return obj[_eval_node(node.slice, variables)]
|
|
231
|
+
if isinstance(node, ast.Slice):
|
|
232
|
+
lower = _eval_node(node.lower, variables) if node.lower else None
|
|
233
|
+
upper = _eval_node(node.upper, variables) if node.upper else None
|
|
234
|
+
step = _eval_node(node.step, variables) if node.step else None
|
|
235
|
+
return slice(lower, upper, step)
|
|
236
|
+
if isinstance(node, ast.Attribute):
|
|
237
|
+
obj = _eval_node(node.value, variables)
|
|
238
|
+
if node.attr.startswith("_"):
|
|
239
|
+
raise DebugError(f"attribute {node.attr!r} is not allowed")
|
|
240
|
+
return getattr(obj, node.attr)
|
|
241
|
+
if isinstance(node, ast.Call):
|
|
242
|
+
if isinstance(node.func, ast.Name):
|
|
243
|
+
func: Any = _SAFE_FUNCS.get(node.func.id)
|
|
244
|
+
if func is None:
|
|
245
|
+
raise DebugError(f"function {node.func.id!r} is not allowed")
|
|
246
|
+
elif isinstance(node.func, ast.Attribute):
|
|
247
|
+
attr = node.func.attr
|
|
248
|
+
if attr in _BLOCKED_METHODS:
|
|
249
|
+
raise DebugError(f"method {attr!r} is not allowed (mutating)")
|
|
250
|
+
func = _eval_node(node.func, variables)
|
|
251
|
+
else:
|
|
252
|
+
raise DebugError("only plain and method calls are allowed")
|
|
253
|
+
args = [_eval_node(arg, variables) for arg in node.args]
|
|
254
|
+
kwargs = {
|
|
255
|
+
kw.arg: _eval_node(kw.value, variables) for kw in node.keywords if kw.arg is not None
|
|
256
|
+
}
|
|
257
|
+
if any(kw.arg is None for kw in node.keywords):
|
|
258
|
+
raise DebugError("**kwargs is not allowed")
|
|
259
|
+
return func(*args, **kwargs)
|
|
260
|
+
if isinstance(node, ast.IfExp):
|
|
261
|
+
branch = node.body if _eval_node(node.test, variables) else node.orelse
|
|
262
|
+
return _eval_node(branch, variables)
|
|
263
|
+
if isinstance(node, ast.List):
|
|
264
|
+
return [_eval_node(item, variables) for item in node.elts]
|
|
265
|
+
if isinstance(node, ast.Tuple):
|
|
266
|
+
return tuple(_eval_node(item, variables) for item in node.elts)
|
|
267
|
+
if isinstance(node, ast.Set):
|
|
268
|
+
return {_eval_node(item, variables) for item in node.elts}
|
|
269
|
+
if isinstance(node, ast.Dict):
|
|
270
|
+
return {
|
|
271
|
+
_eval_node(key, variables) if key is not None else None: _eval_node(value, variables)
|
|
272
|
+
for key, value in zip(node.keys, node.values, strict=True)
|
|
273
|
+
}
|
|
274
|
+
if isinstance(node, ast.JoinedStr):
|
|
275
|
+
raise DebugError("f-strings are not supported; use str(...) instead")
|
|
276
|
+
raise DebugError(f"unsupported syntax: {type(node).__name__}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _eval_repl(expression: str, variables: dict[str, Any]) -> str:
|
|
280
|
+
if len(expression) > _MAX_EXPR_LEN:
|
|
281
|
+
raise DebugError(f"expression too long (max {_MAX_EXPR_LEN} chars)")
|
|
282
|
+
try:
|
|
283
|
+
tree = ast.parse(expression, mode="exec")
|
|
284
|
+
except SyntaxError as exc:
|
|
285
|
+
raise DebugError(f"invalid syntax: {exc}") from exc
|
|
286
|
+
node_count = sum(1 for _ in ast.walk(tree))
|
|
287
|
+
if node_count > _MAX_EXPR_NODES:
|
|
288
|
+
raise DebugError(f"expression too complex (max {_MAX_EXPR_NODES} nodes)")
|
|
289
|
+
if len(tree.body) != 1:
|
|
290
|
+
raise DebugError("exactly one expression or one assignment is allowed")
|
|
291
|
+
stmt = tree.body[0]
|
|
292
|
+
if isinstance(stmt, ast.Assign):
|
|
293
|
+
if len(stmt.targets) != 1 or not isinstance(stmt.targets[0], ast.Name):
|
|
294
|
+
raise DebugError("only simple name assignment is supported (name = expr)")
|
|
295
|
+
value = _eval_node(stmt.value, variables)
|
|
296
|
+
variables[stmt.targets[0].id] = value
|
|
297
|
+
return short(repr(value))
|
|
298
|
+
if isinstance(stmt, ast.Expr):
|
|
299
|
+
return short(repr(_eval_node(stmt.value, variables)))
|
|
300
|
+
raise DebugError("only expressions and name assignments are supported")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class FlowDebugger:
|
|
304
|
+
"""Runs one flow at a time with step/resume control and a shared scope."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, registry: ToolRegistry) -> None:
|
|
307
|
+
self._registry = registry
|
|
308
|
+
self._runner: FlowRunner | None = None
|
|
309
|
+
self._task: asyncio.Task[None] | None = None
|
|
310
|
+
self._resume = asyncio.Event()
|
|
311
|
+
self._status = "idle"
|
|
312
|
+
self._mode = "step"
|
|
313
|
+
self._pause_requested = False
|
|
314
|
+
self._current: str | None = None
|
|
315
|
+
self._error: str | None = None
|
|
316
|
+
self._variables: dict[str, Any] = {}
|
|
317
|
+
self._breakpoints: set[str] = set()
|
|
318
|
+
self._edges: list[dict[str, Any]] = []
|
|
319
|
+
self._log_entries: list[dict[str, Any]] = []
|
|
320
|
+
self._repl: list[dict[str, Any]] = []
|
|
321
|
+
self._session = 0
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------ API
|
|
324
|
+
|
|
325
|
+
def start(self, doc: dict[str, Any], *, dev_capture: bool = False) -> dict[str, Any]:
|
|
326
|
+
if self._task is not None and not self._task.done():
|
|
327
|
+
raise DebugError("a debug session is already active — stop it first")
|
|
328
|
+
if doc.get("version") != FLOW_VERSION:
|
|
329
|
+
raise DebugError(f"unsupported flow version {doc.get('version')!r}")
|
|
330
|
+
start = next((n for n in doc.get("nodes") or [] if n.get("kind") == "start"), None)
|
|
331
|
+
if start is None:
|
|
332
|
+
raise DebugError("flow has no start node")
|
|
333
|
+
self._session += 1
|
|
334
|
+
self._resume = asyncio.Event()
|
|
335
|
+
self._status = "paused"
|
|
336
|
+
self._mode = "step"
|
|
337
|
+
self._pause_requested = False
|
|
338
|
+
self._current = str(start["id"])
|
|
339
|
+
self._error = None
|
|
340
|
+
self._variables = {}
|
|
341
|
+
self._breakpoints = {str(b) for b in (doc.get("breakpoints") or []) if isinstance(b, str)}
|
|
342
|
+
self._edges = list(doc.get("edges") or [])
|
|
343
|
+
self._log_entries = []
|
|
344
|
+
self._runner = FlowRunner(
|
|
345
|
+
self._registry,
|
|
346
|
+
variables=self._variables,
|
|
347
|
+
edges=self._edges,
|
|
348
|
+
log=self._log,
|
|
349
|
+
dev_capture=dev_capture,
|
|
350
|
+
)
|
|
351
|
+
self._task = asyncio.get_running_loop().create_task(self._run(doc))
|
|
352
|
+
return self.state()
|
|
353
|
+
|
|
354
|
+
def step(self) -> None:
|
|
355
|
+
"""Run the current node and pause before the next one."""
|
|
356
|
+
self._mode = "step"
|
|
357
|
+
self._resume.set()
|
|
358
|
+
|
|
359
|
+
def resume(self) -> None:
|
|
360
|
+
"""Run without pausing until the flow finishes or errors."""
|
|
361
|
+
self._mode = "run"
|
|
362
|
+
self._pause_requested = False
|
|
363
|
+
self._resume.set()
|
|
364
|
+
|
|
365
|
+
def pause(self) -> None:
|
|
366
|
+
"""Request a pause at the next node gate."""
|
|
367
|
+
self._pause_requested = True
|
|
368
|
+
|
|
369
|
+
def set_breakpoint(self, node_id: str, enabled: bool) -> None:
|
|
370
|
+
"""Toggle a breakpoint on a node for the current/next session."""
|
|
371
|
+
if enabled:
|
|
372
|
+
self._breakpoints.add(node_id)
|
|
373
|
+
else:
|
|
374
|
+
self._breakpoints.discard(node_id)
|
|
375
|
+
|
|
376
|
+
def stop(self) -> None:
|
|
377
|
+
task = self._task
|
|
378
|
+
self._task = None
|
|
379
|
+
if task is not None and not task.done():
|
|
380
|
+
session = self._session
|
|
381
|
+
task.cancel()
|
|
382
|
+
# Safety net: if a new session started before the old task finished
|
|
383
|
+
# cancelling, do not clobber the new session's state.
|
|
384
|
+
task.add_done_callback(lambda _t: self._on_stale_task_done(session))
|
|
385
|
+
self._log("info", "debug session stopped")
|
|
386
|
+
self._status = "idle"
|
|
387
|
+
self._current = None
|
|
388
|
+
self._mode = "step"
|
|
389
|
+
self._resume = asyncio.Event()
|
|
390
|
+
self._breakpoints.clear()
|
|
391
|
+
self._runner = None
|
|
392
|
+
|
|
393
|
+
def _on_stale_task_done(self, session: int) -> None:
|
|
394
|
+
if self._session != session:
|
|
395
|
+
return
|
|
396
|
+
self._task = None
|
|
397
|
+
if self._status in ("running", "paused", "error"):
|
|
398
|
+
self._status = "idle"
|
|
399
|
+
self._current = None
|
|
400
|
+
|
|
401
|
+
def eval(self, expression: str) -> dict[str, Any]:
|
|
402
|
+
"""Evaluate a REPL expression against (and mutating) the scope."""
|
|
403
|
+
entry: dict[str, Any]
|
|
404
|
+
try:
|
|
405
|
+
entry = {
|
|
406
|
+
"ts": time.time(),
|
|
407
|
+
"expression": expression,
|
|
408
|
+
"result": _eval_repl(expression, self._variables),
|
|
409
|
+
"error": None,
|
|
410
|
+
}
|
|
411
|
+
except Exception as exc: # noqa: BLE001 - REPL reports any failure
|
|
412
|
+
entry = {
|
|
413
|
+
"ts": time.time(),
|
|
414
|
+
"expression": expression,
|
|
415
|
+
"result": "",
|
|
416
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
417
|
+
}
|
|
418
|
+
self._repl.append(entry)
|
|
419
|
+
del self._repl[:-_MAX_REPL]
|
|
420
|
+
return {"result": entry["result"], "error": entry["error"]}
|
|
421
|
+
|
|
422
|
+
def state(self) -> dict[str, Any]:
|
|
423
|
+
return {
|
|
424
|
+
"status": self._status,
|
|
425
|
+
"current_node": self._current,
|
|
426
|
+
"error": self._error,
|
|
427
|
+
"variables": _truncate_variables(self._variables),
|
|
428
|
+
"log": self._log_entries,
|
|
429
|
+
"repl": self._repl,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
# --------------------------------------------------------------- control
|
|
433
|
+
|
|
434
|
+
def _log(self, level: str, msg: str) -> None:
|
|
435
|
+
self._log_entries.append({"ts": time.time(), "level": level, "msg": short(msg, _LOG_LIMIT)})
|
|
436
|
+
del self._log_entries[:-_MAX_LOG]
|
|
437
|
+
|
|
438
|
+
async def _wait_gate(self) -> None:
|
|
439
|
+
# While a failed node awaits a retry the status stays "error".
|
|
440
|
+
if self._error is not None:
|
|
441
|
+
self._status = "error"
|
|
442
|
+
elif self._mode == "run" and not self._pause_requested:
|
|
443
|
+
self._status = "running"
|
|
444
|
+
else:
|
|
445
|
+
self._status = "paused"
|
|
446
|
+
if self._mode == "run" and not self._pause_requested and self._error is None:
|
|
447
|
+
return
|
|
448
|
+
self._pause_requested = False
|
|
449
|
+
self._log("debug", f"paused at node {self._current}")
|
|
450
|
+
await self._resume.wait()
|
|
451
|
+
self._resume.clear()
|
|
452
|
+
|
|
453
|
+
async def _run(self, doc: dict[str, Any]) -> None:
|
|
454
|
+
nodes = {str(n["id"]): n for n in doc.get("nodes") or []}
|
|
455
|
+
start = next((n for n in doc.get("nodes") or [] if n.get("kind") == "start"), None)
|
|
456
|
+
node: dict[str, Any] | None = start
|
|
457
|
+
runner = self._runner
|
|
458
|
+
assert runner is not None
|
|
459
|
+
try:
|
|
460
|
+
while node is not None:
|
|
461
|
+
self._current = str(node["id"])
|
|
462
|
+
if (
|
|
463
|
+
self._current in self._breakpoints
|
|
464
|
+
and self._error is None
|
|
465
|
+
and self._mode == "run"
|
|
466
|
+
):
|
|
467
|
+
self._pause_requested = True
|
|
468
|
+
self._log("debug", f"breakpoint hit at node {self._current}")
|
|
469
|
+
while True:
|
|
470
|
+
await self._wait_gate()
|
|
471
|
+
try:
|
|
472
|
+
next_id = await runner.execute_node(node)
|
|
473
|
+
self._error = None
|
|
474
|
+
break
|
|
475
|
+
except asyncio.CancelledError:
|
|
476
|
+
raise
|
|
477
|
+
except Exception as exc: # noqa: BLE001 - pause for inspection
|
|
478
|
+
self._error = f"{type(exc).__name__}: {exc}"
|
|
479
|
+
self._status = "error"
|
|
480
|
+
self._mode = "step"
|
|
481
|
+
self._resume.clear()
|
|
482
|
+
self._log("error", f"node {node['id']} failed — step to retry")
|
|
483
|
+
if next_id is None:
|
|
484
|
+
self._log("info", "flow finished")
|
|
485
|
+
break
|
|
486
|
+
node = nodes.get(next_id)
|
|
487
|
+
self._status = "finished"
|
|
488
|
+
self._current = None
|
|
489
|
+
except asyncio.CancelledError:
|
|
490
|
+
raise
|
|
491
|
+
except Exception as exc: # noqa: BLE001 - never leave a stale status
|
|
492
|
+
self._error = f"{type(exc).__name__}: {exc}"
|
|
493
|
+
self._status = "error"
|
|
494
|
+
self._current = None
|