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.
@@ -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