Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c8e1653c | |||
| 58e4fa918c | |||
| d2eb86e534 | |||
| 23a7b080eb | |||
| bf39bcdec9 | |||
| 0276632491 | |||
| ae2993d0d1 | |||
| d14d71f760 | |||
| 738641d35f | |||
| 22f5534f08 | |||
| b79e7eca73 | |||
| 28250dc45e | |||
| fe5df6a87a | |||
| ff7b5c7e27 |
@@ -1,4 +1,11 @@
|
||||
.PHONY: lint format check test install-hooks help frontend-install frontend-dev frontend-build
|
||||
.PHONY: lint format check test test-tools test-live test-all install-hooks help frontend-install frontend-dev frontend-build
|
||||
|
||||
# ── Ensure uv is findable in Git Bash on Windows ──────────────────────────────
|
||||
# uv installs to ~/.local/bin on Windows/Linux/macOS. Git Bash may not include
|
||||
# this in PATH by default, so we prepend it here.
|
||||
export PATH := $(HOME)/.local/bin:$(PATH)
|
||||
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
@@ -46,4 +53,4 @@ frontend-dev: ## Start frontend dev server
|
||||
cd core/frontend && npm run dev
|
||||
|
||||
frontend-build: ## Build frontend for production
|
||||
cd core/frontend && npm run build
|
||||
cd core/frontend && npm run build
|
||||
@@ -1144,6 +1144,8 @@ Batch your response — do not call run_agent_with_input() once per trigger.
|
||||
config since last run), skip it and inform the user.
|
||||
- Never disable a trigger without telling the user. Use remove_trigger() only \
|
||||
when explicitly asked or when the trigger is clearly obsolete.
|
||||
- When the user asks to remove or disable a trigger, you MUST call remove_trigger(trigger_id). \
|
||||
Never just say "it's removed" without actually calling the tool.
|
||||
"""
|
||||
|
||||
# -- Backward-compatible composed versions (used by queen_node.system_prompt default) --
|
||||
|
||||
@@ -504,9 +504,21 @@ class EventLoopNode(NodeProtocol):
|
||||
_restored_tool_fingerprints = []
|
||||
|
||||
# Fresh conversation: either isolated mode or first node in continuous mode.
|
||||
from framework.graph.prompt_composer import _with_datetime
|
||||
from framework.graph.prompt_composer import (
|
||||
EXECUTION_SCOPE_PREAMBLE,
|
||||
_with_datetime,
|
||||
)
|
||||
|
||||
system_prompt = _with_datetime(ctx.node_spec.system_prompt or "")
|
||||
# Prepend execution-scope preamble for worker nodes so the
|
||||
# LLM knows it is one step in a pipeline and should not try
|
||||
# to perform work that belongs to other nodes.
|
||||
if (
|
||||
not ctx.is_subagent_mode
|
||||
and ctx.node_spec.node_type in ("event_loop", "gcu")
|
||||
and ctx.node_spec.output_keys
|
||||
):
|
||||
system_prompt = f"{EXECUTION_SCOPE_PREAMBLE}\n\n{system_prompt}"
|
||||
# Prepend GCU browser best-practices prompt for gcu nodes
|
||||
if ctx.node_spec.node_type == "gcu":
|
||||
from framework.graph.gcu import GCU_BROWSER_SYSTEM_PROMPT
|
||||
|
||||
@@ -1420,6 +1420,7 @@ class GraphExecutor:
|
||||
next_spec = graph.get_node(current_node_id)
|
||||
if next_spec and next_spec.node_type == "event_loop":
|
||||
from framework.graph.prompt_composer import (
|
||||
EXECUTION_SCOPE_PREAMBLE,
|
||||
build_accounts_prompt,
|
||||
build_narrative,
|
||||
build_transition_marker,
|
||||
@@ -1459,9 +1460,14 @@ class GraphExecutor:
|
||||
)
|
||||
|
||||
# Compose new system prompt (Layer 1 + 2 + 3 + accounts)
|
||||
# Prepend scope preamble to focus so the LLM stays
|
||||
# within this node's responsibility.
|
||||
_focus = next_spec.system_prompt
|
||||
if next_spec.output_keys and _focus:
|
||||
_focus = f"{EXECUTION_SCOPE_PREAMBLE}\n\n{_focus}"
|
||||
new_system = compose_system_prompt(
|
||||
identity_prompt=getattr(graph, "identity_prompt", None),
|
||||
focus_prompt=next_spec.system_prompt,
|
||||
focus_prompt=_focus,
|
||||
narrative=narrative,
|
||||
accounts_prompt=_node_accounts,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,16 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected into every worker node's system prompt so the LLM understands
|
||||
# it is one step in a multi-node pipeline and should not overreach.
|
||||
EXECUTION_SCOPE_PREAMBLE = (
|
||||
"EXECUTION SCOPE: You are one node in a multi-step workflow graph. "
|
||||
"Focus ONLY on the task described in your instructions below. "
|
||||
"Call set_output() for each of your declared output keys, then stop. "
|
||||
"Do NOT attempt work that belongs to other nodes — the framework "
|
||||
"routes data between nodes automatically."
|
||||
)
|
||||
|
||||
|
||||
def _with_datetime(prompt: str) -> str:
|
||||
"""Append current datetime with local timezone to a system prompt."""
|
||||
@@ -306,6 +316,12 @@ def build_transition_marker(
|
||||
# Next phase
|
||||
sections.append(f"\nNow entering: {next_node.name}")
|
||||
sections.append(f" {next_node.description}")
|
||||
if next_node.output_keys:
|
||||
sections.append(
|
||||
f"\nYour ONLY job in this phase: complete the task above and call "
|
||||
f"set_output() for {next_node.output_keys}. Do NOT do work that "
|
||||
f"belongs to later phases."
|
||||
)
|
||||
|
||||
# Reflection prompt (engineered metacognition)
|
||||
sections.append(
|
||||
|
||||
@@ -115,11 +115,23 @@ class SafeEvalVisitor(ast.NodeVisitor):
|
||||
return True
|
||||
|
||||
def visit_BoolOp(self, node: ast.BoolOp) -> Any:
|
||||
values = [self.visit(v) for v in node.values]
|
||||
# Short-circuit evaluation to match Python semantics.
|
||||
# Previously all operands were eagerly evaluated, which broke
|
||||
# guard patterns like: ``x is not None and x.get("key")``
|
||||
if isinstance(node.op, ast.And):
|
||||
return all(values)
|
||||
result = True
|
||||
for v in node.values:
|
||||
result = self.visit(v)
|
||||
if not result:
|
||||
return result
|
||||
return result
|
||||
elif isinstance(node.op, ast.Or):
|
||||
return any(values)
|
||||
result = False
|
||||
for v in node.values:
|
||||
result = self.visit(v)
|
||||
if result:
|
||||
return result
|
||||
return result
|
||||
raise ValueError(f"Boolean operator {type(node.op).__name__} is not allowed")
|
||||
|
||||
def visit_IfExp(self, node: ast.IfExp) -> Any:
|
||||
|
||||
@@ -159,6 +159,7 @@ class EventType(StrEnum):
|
||||
TRIGGER_DEACTIVATED = "trigger_deactivated"
|
||||
TRIGGER_FIRED = "trigger_fired"
|
||||
TRIGGER_REMOVED = "trigger_removed"
|
||||
TRIGGER_UPDATED = "trigger_updated"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_EVENT_TYPES = [
|
||||
EventType.TRIGGER_DEACTIVATED,
|
||||
EventType.TRIGGER_FIRED,
|
||||
EventType.TRIGGER_REMOVED,
|
||||
EventType.TRIGGER_UPDATED,
|
||||
EventType.DRAFT_GRAPH_UPDATED,
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ Worker session browsing (persisted execution runs on disk):
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
@@ -408,7 +410,7 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_update_trigger_task(request: web.Request) -> web.Response:
|
||||
"""PATCH /api/sessions/{session_id}/triggers/{trigger_id} — update trigger task."""
|
||||
"""PATCH /api/sessions/{session_id}/triggers/{trigger_id} — update trigger fields."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
@@ -427,30 +429,136 @@ async def handle_update_trigger_task(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
|
||||
task = body.get("task")
|
||||
if task is None:
|
||||
return web.json_response({"error": "Missing 'task' field"}, status=400)
|
||||
if not isinstance(task, str):
|
||||
return web.json_response({"error": "'task' must be a string"}, status=400)
|
||||
updates: dict[str, object] = {}
|
||||
|
||||
tdef.task = task
|
||||
if "task" in body:
|
||||
task = body.get("task")
|
||||
if not isinstance(task, str):
|
||||
return web.json_response({"error": "'task' must be a string"}, status=400)
|
||||
tdef.task = task
|
||||
updates["task"] = tdef.task
|
||||
|
||||
trigger_config_update = body.get("trigger_config")
|
||||
if trigger_config_update is not None:
|
||||
if not isinstance(trigger_config_update, dict):
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config' must be an object"},
|
||||
status=400,
|
||||
)
|
||||
merged_trigger_config = dict(tdef.trigger_config)
|
||||
merged_trigger_config.update(trigger_config_update)
|
||||
|
||||
if tdef.trigger_type == "timer":
|
||||
cron_expr = merged_trigger_config.get("cron")
|
||||
interval = merged_trigger_config.get("interval_minutes")
|
||||
if cron_expr is not None and not isinstance(cron_expr, str):
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config.cron' must be a string"},
|
||||
status=400,
|
||||
)
|
||||
if cron_expr:
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
if not croniter.is_valid(cron_expr):
|
||||
return web.json_response(
|
||||
{"error": f"Invalid cron expression: {cron_expr}"},
|
||||
status=400,
|
||||
)
|
||||
except ImportError:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"croniter package not installed — cannot validate cron expression."
|
||||
)
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
merged_trigger_config.pop("interval_minutes", None)
|
||||
elif interval is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"Timer trigger needs 'cron' or 'interval_minutes' in trigger_config."
|
||||
)
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
elif not isinstance(interval, (int, float)) or interval <= 0:
|
||||
return web.json_response(
|
||||
{"error": "'trigger_config.interval_minutes' must be > 0"},
|
||||
status=400,
|
||||
)
|
||||
tdef.trigger_config = merged_trigger_config
|
||||
updates["trigger_config"] = tdef.trigger_config
|
||||
|
||||
if not updates:
|
||||
return web.json_response(
|
||||
{"error": "Provide at least one of 'task' or 'trigger_config'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Persist to session state and agent definition
|
||||
from framework.tools.queen_lifecycle_tools import (
|
||||
_persist_active_triggers,
|
||||
_save_trigger_to_agent,
|
||||
_start_trigger_timer,
|
||||
_start_trigger_webhook,
|
||||
)
|
||||
|
||||
if "trigger_config" in updates and trigger_id in getattr(session, "active_trigger_ids", set()):
|
||||
task = session.active_timer_tasks.pop(trigger_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
getattr(session, "trigger_next_fire", {}).pop(trigger_id, None)
|
||||
|
||||
webhook_subs = getattr(session, "active_webhook_subs", {})
|
||||
if sub_id := webhook_subs.pop(trigger_id, None):
|
||||
with contextlib.suppress(Exception):
|
||||
session.event_bus.unsubscribe(sub_id)
|
||||
|
||||
if tdef.trigger_type == "timer":
|
||||
await _start_trigger_timer(session, trigger_id, tdef)
|
||||
elif tdef.trigger_type == "webhook":
|
||||
await _start_trigger_webhook(session, trigger_id, tdef)
|
||||
|
||||
if trigger_id in getattr(session, "active_trigger_ids", set()):
|
||||
session_id = request.match_info["session_id"]
|
||||
await _persist_active_triggers(session, session_id)
|
||||
|
||||
_save_trigger_to_agent(session, trigger_id, tdef)
|
||||
|
||||
# Emit SSE event so the frontend updates the graph and detail panel
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_UPDATED,
|
||||
stream_id="queen",
|
||||
data={
|
||||
"trigger_id": trigger_id,
|
||||
"task": tdef.task,
|
||||
"trigger_config": tdef.trigger_config,
|
||||
"trigger_type": tdef.trigger_type,
|
||||
"name": tdef.description or trigger_id,
|
||||
"entry_node": getattr(
|
||||
getattr(getattr(session, "runner", None), "graph", None),
|
||||
"entry_node",
|
||||
None,
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"trigger_id": trigger_id,
|
||||
"task": tdef.task,
|
||||
"trigger_config": tdef.trigger_config,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -868,6 +868,10 @@ class SessionManager:
|
||||
event_type = (
|
||||
EventType.TRIGGER_AVAILABLE if kind == "available" else EventType.TRIGGER_REMOVED
|
||||
)
|
||||
# Resolve graph entry node for trigger target
|
||||
runner = getattr(session, "runner", None)
|
||||
graph_entry = runner.graph.entry_node if runner else None
|
||||
|
||||
for t in triggers.values():
|
||||
await session.event_bus.publish(
|
||||
AgentEvent(
|
||||
@@ -877,6 +881,8 @@ class SessionManager:
|
||||
"trigger_id": t.id,
|
||||
"trigger_type": t.trigger_type,
|
||||
"trigger_config": t.trigger_config,
|
||||
"name": t.description or t.id,
|
||||
**({"entry_node": graph_entry} if graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ Uses aiohttp TestClient with mocked sessions to test all endpoints
|
||||
without requiring actual LLM calls or agent loading.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from framework.runtime.triggers import TriggerDefinition
|
||||
from framework.server.app import create_app
|
||||
from framework.server.session_manager import Session
|
||||
|
||||
@@ -172,6 +174,7 @@ def _make_session(
|
||||
runner.intro_message = "Test intro"
|
||||
|
||||
mock_event_bus = MagicMock()
|
||||
mock_event_bus.publish = AsyncMock()
|
||||
mock_llm = MagicMock()
|
||||
|
||||
queen_executor = _make_queen_executor() if with_queen else None
|
||||
@@ -484,6 +487,70 @@ class TestSessionCRUD:
|
||||
data = await resp.json()
|
||||
assert "primary" in data["graphs"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_task(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Old task",
|
||||
)
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"task": "New task"},
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["task"] == "New task"
|
||||
assert data["trigger_config"]["cron"] == "0 5 * * *"
|
||||
assert session.available_triggers["daily"].task == "New task"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_cron_restarts_active_timer(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Run task",
|
||||
active=True,
|
||||
)
|
||||
session.active_trigger_ids.add("daily")
|
||||
session.active_timer_tasks["daily"] = asyncio.create_task(asyncio.sleep(60))
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"trigger_config": {"cron": "0 6 * * *"}},
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["trigger_config"]["cron"] == "0 6 * * *"
|
||||
assert "daily" in session.active_timer_tasks
|
||||
assert session.active_timer_tasks["daily"] is not None
|
||||
assert session.available_triggers["daily"].trigger_config["cron"] == "0 6 * * *"
|
||||
session.active_timer_tasks["daily"].cancel()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_cron_rejects_invalid_expression(self, tmp_path):
|
||||
session = _make_session(tmp_dir=tmp_path)
|
||||
session.available_triggers["daily"] = TriggerDefinition(
|
||||
id="daily",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 5 * * *"},
|
||||
task="Run task",
|
||||
)
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.patch(
|
||||
"/api/sessions/test_agent/triggers/daily",
|
||||
json={"trigger_config": {"cron": "not a cron"}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
class TestExecution:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -3702,6 +3702,8 @@ def register_queen_lifecycle_tools(
|
||||
_save_trigger_to_agent(session, trigger_id, tdef)
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
_runner = getattr(session, "runner", None)
|
||||
_graph_entry = _runner.graph.entry_node if _runner else None
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_ACTIVATED,
|
||||
@@ -3710,6 +3712,8 @@ def register_queen_lifecycle_tools(
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": t_type,
|
||||
"trigger_config": t_config,
|
||||
"name": tdef.description or trigger_id,
|
||||
**({"entry_node": _graph_entry} if _graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -3762,6 +3766,8 @@ def register_queen_lifecycle_tools(
|
||||
# Emit event
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus:
|
||||
_runner = getattr(session, "runner", None)
|
||||
_graph_entry = _runner.graph.entry_node if _runner else None
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_ACTIVATED,
|
||||
@@ -3770,6 +3776,8 @@ def register_queen_lifecycle_tools(
|
||||
"trigger_id": trigger_id,
|
||||
"trigger_type": t_type,
|
||||
"trigger_config": t_config,
|
||||
"name": tdef.description or trigger_id,
|
||||
**({"entry_node": _graph_entry} if _graph_entry else {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -3868,7 +3876,10 @@ def register_queen_lifecycle_tools(
|
||||
AgentEvent(
|
||||
type=EventType.TRIGGER_DEACTIVATED,
|
||||
stream_id="queen",
|
||||
data={"trigger_id": trigger_id},
|
||||
data={
|
||||
"trigger_id": trigger_id,
|
||||
"name": tdef.description or trigger_id if tdef else trigger_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -64,10 +64,14 @@ export const sessionsApi = {
|
||||
`/sessions/${sessionId}/entry-points`,
|
||||
),
|
||||
|
||||
updateTriggerTask: (sessionId: string, triggerId: string, task: string) =>
|
||||
api.patch<{ trigger_id: string; task: string }>(
|
||||
updateTrigger: (
|
||||
sessionId: string,
|
||||
triggerId: string,
|
||||
patch: { task?: string; trigger_config?: Record<string, unknown> },
|
||||
) =>
|
||||
api.patch<{ trigger_id: string; task: string; trigger_config: Record<string, unknown> }>(
|
||||
`/sessions/${sessionId}/triggers/${triggerId}`,
|
||||
{ task },
|
||||
patch,
|
||||
),
|
||||
|
||||
graphs: (sessionId: string) =>
|
||||
|
||||
@@ -337,7 +337,8 @@ export type EventTypeName =
|
||||
| "trigger_activated"
|
||||
| "trigger_deactivated"
|
||||
| "trigger_fired"
|
||||
| "trigger_removed";
|
||||
| "trigger_removed"
|
||||
| "trigger_updated";
|
||||
|
||||
export interface AgentEvent {
|
||||
type: EventTypeName;
|
||||
|
||||
@@ -3,11 +3,23 @@ import { Loader2 } from "lucide-react";
|
||||
import type { DraftGraph as DraftGraphData, DraftNode } from "@/api/types";
|
||||
import { RunButton } from "./RunButton";
|
||||
import type { GraphNode, RunState } from "./graph-types";
|
||||
import {
|
||||
cssVar,
|
||||
truncateLabel,
|
||||
TRIGGER_ICONS,
|
||||
ACTIVE_TRIGGER_COLORS,
|
||||
useTriggerColors,
|
||||
} from "@/lib/graphUtils";
|
||||
|
||||
// Read a CSS custom property value (space-separated HSL components)
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
// ── Trigger layout constants ──
|
||||
const TRIGGER_H = 38; // pill height
|
||||
const TRIGGER_PILL_GAP_X = 16; // horizontal gap between multiple trigger pills
|
||||
const TRIGGER_ICON_X = 16; // icon center offset from pill left edge
|
||||
const TRIGGER_LABEL_X = 30; // label start offset from pill left edge
|
||||
const TRIGGER_LABEL_INSET = 38; // icon + padding subtracted from pill width for label space
|
||||
const TRIGGER_TEXT_Y = 11; // y-offset below pill for first text line (countdown or status)
|
||||
const TRIGGER_TEXT_STEP = 11; // additional y-offset for second text line when countdown present
|
||||
const TRIGGER_CLEARANCE = 30; // vertical space below pill for countdown + status text
|
||||
|
||||
interface DraftChromeColors {
|
||||
edge: string;
|
||||
@@ -107,13 +119,6 @@ function formatNodeId(id: string): string {
|
||||
return id.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, availablePx: number, fontSize: number): string {
|
||||
const avgCharW = fontSize * 0.58;
|
||||
const maxChars = Math.floor(availablePx / avgCharW);
|
||||
if (label.length <= maxChars) return label;
|
||||
return label.slice(0, Math.max(maxChars - 1, 1)) + "\u2026";
|
||||
}
|
||||
|
||||
/** Return the bounding-rect corner radius for a given flowchart shape. */
|
||||
/**
|
||||
* Render an ISO 5807 flowchart shape as an SVG element.
|
||||
@@ -240,6 +245,13 @@ export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchar
|
||||
const runBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [containerW, setContainerW] = useState(484);
|
||||
const chrome = useDraftChromeColors();
|
||||
const triggerColors = useTriggerColors();
|
||||
|
||||
// Extract trigger nodes from runtimeNodes
|
||||
const triggerNodes = useMemo(
|
||||
() => (runtimeNodes ?? []).filter(n => n.nodeType === "trigger"),
|
||||
[runtimeNodes],
|
||||
);
|
||||
|
||||
// ── Entrance animation — fires when originalDraft becomes a new non-null value ──
|
||||
// This covers: agent loaded, build finished, queen modifies flowchart.
|
||||
@@ -709,12 +721,17 @@ export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchar
|
||||
return { nodeYOffset: offsets, totalExtraY: totalExtra, groupBoxMaxX: maxGroupX };
|
||||
}, [nodes, maxLayer, flowchartMap, idxMap, layers, nodeXPositions, nodeW]);
|
||||
|
||||
// When triggers are present, push the entire draft graph down to make room
|
||||
const triggerOffsetY = triggerNodes.length > 0
|
||||
? TRIGGER_H + TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP + TRIGGER_CLEARANCE
|
||||
: 0;
|
||||
|
||||
const nodePos = (i: number) => ({
|
||||
x: nodeXPositions[i],
|
||||
y: TOP_Y + layers[i] * (NODE_H + GAP_Y) + nodeYOffset[i],
|
||||
y: TOP_Y + triggerOffsetY + layers[i] * (NODE_H + GAP_Y) + nodeYOffset[i],
|
||||
});
|
||||
|
||||
const svgHeight = TOP_Y + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y + totalExtraY + 16;
|
||||
const svgHeight = TOP_Y + triggerOffsetY + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y + totalExtraY + 16;
|
||||
|
||||
// Compute group areas for runtime node boundaries on the draft
|
||||
const groupAreas = useMemo(() => {
|
||||
@@ -847,6 +864,131 @@ export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchar
|
||||
pending: "",
|
||||
};
|
||||
|
||||
// ── Trigger node rendering ──
|
||||
|
||||
const triggerW = Math.min(nodeW, 180);
|
||||
|
||||
// Shared trigger pill X position (used by both node and edge renderers)
|
||||
const triggerPillX = (idx: number) => {
|
||||
const totalW = triggerNodes.length * triggerW + (triggerNodes.length - 1) * TRIGGER_PILL_GAP_X;
|
||||
return (containerW - totalW) / 2 + idx * (triggerW + TRIGGER_PILL_GAP_X);
|
||||
};
|
||||
|
||||
const renderTriggerNode = (node: GraphNode, triggerIdx: number) => {
|
||||
const icon = TRIGGER_ICONS[node.triggerType || ""] || "\u26A1";
|
||||
const isActive = node.status === "running" || node.status === "complete";
|
||||
const colors = isActive ? ACTIVE_TRIGGER_COLORS : triggerColors;
|
||||
const nextFireIn = node.triggerConfig?.next_fire_in as number | undefined;
|
||||
|
||||
const tx = triggerPillX(triggerIdx);
|
||||
const ty = TOP_Y;
|
||||
|
||||
const fontSize = triggerW < 140 ? 10.5 : 11.5;
|
||||
const displayLabel = truncateLabel(node.label, triggerW - TRIGGER_LABEL_INSET, fontSize);
|
||||
|
||||
// Countdown
|
||||
let countdownLabel: string | null = null;
|
||||
if (isActive && nextFireIn != null && nextFireIn > 0) {
|
||||
const h = Math.floor(nextFireIn / 3600);
|
||||
const m = Math.floor((nextFireIn % 3600) / 60);
|
||||
const s = Math.floor(nextFireIn % 60);
|
||||
countdownLabel = h > 0
|
||||
? `next in ${h}h ${String(m).padStart(2, "0")}m`
|
||||
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
const statusLabel = isActive ? "active" : "inactive";
|
||||
const statusColor = isActive ? "hsl(140,40%,50%)" : "hsl(210,20%,40%)";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onClick={() => onRuntimeNodeClick?.(node.id)}
|
||||
style={{ cursor: onRuntimeNodeClick ? "pointer" : "default" }}
|
||||
>
|
||||
<title>{node.label}</title>
|
||||
{/* Pill-shaped background */}
|
||||
<rect
|
||||
x={tx} y={ty}
|
||||
width={triggerW} height={TRIGGER_H}
|
||||
rx={TRIGGER_H / 2}
|
||||
fill={colors.bg}
|
||||
stroke={colors.border}
|
||||
strokeWidth={isActive ? 1.5 : 1}
|
||||
strokeDasharray={isActive ? undefined : "4 2"}
|
||||
/>
|
||||
{/* Icon */}
|
||||
<text
|
||||
x={tx + TRIGGER_ICON_X} y={ty + TRIGGER_H / 2}
|
||||
fill={colors.icon} fontSize={13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
>
|
||||
{icon}
|
||||
</text>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={tx + TRIGGER_LABEL_X} y={ty + TRIGGER_H / 2}
|
||||
fill={colors.text}
|
||||
fontSize={fontSize}
|
||||
fontWeight={500}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
>
|
||||
{displayLabel}
|
||||
</text>
|
||||
{/* Countdown */}
|
||||
{countdownLabel && (
|
||||
<text
|
||||
x={tx + triggerW / 2} y={ty + TRIGGER_H + TRIGGER_TEXT_Y}
|
||||
fill={colors.text} fontSize={9}
|
||||
textAnchor="middle" fontStyle="italic" opacity={0.7}
|
||||
>
|
||||
{countdownLabel}
|
||||
</text>
|
||||
)}
|
||||
{/* Status */}
|
||||
<text
|
||||
x={tx + triggerW / 2} y={ty + TRIGGER_H + (countdownLabel ? TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP : TRIGGER_TEXT_Y)}
|
||||
fill={statusColor} fontSize={8.5}
|
||||
textAnchor="middle" opacity={0.8}
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTriggerEdge = (triggerIdx: number) => {
|
||||
if (nodes.length === 0) return null;
|
||||
const triggerNode = triggerNodes[triggerIdx];
|
||||
const runtimeTargetId = triggerNode?.next?.[0];
|
||||
const targetDraftId = runtimeTargetId
|
||||
? flowchartMap?.[runtimeTargetId]?.[0] ?? runtimeTargetId
|
||||
: draft?.entry_node;
|
||||
const targetIdx = targetDraftId ? idxMap[targetDraftId] ?? 0 : 0;
|
||||
const targetPos = nodePos(targetIdx);
|
||||
const targetX = targetPos.x + nodeW / 2;
|
||||
const targetY = targetPos.y;
|
||||
|
||||
const tx = triggerPillX(triggerIdx) + triggerW / 2;
|
||||
const ty = TOP_Y + TRIGGER_H + TRIGGER_TEXT_Y + TRIGGER_TEXT_STEP + 4;
|
||||
|
||||
const midY = (ty + targetY) / 2;
|
||||
const d = Math.abs(tx - targetX) < 2
|
||||
? `M ${tx} ${ty} L ${targetX} ${targetY}`
|
||||
: `M ${tx} ${ty} L ${tx} ${midY} L ${targetX} ${midY} L ${targetX} ${targetY}`;
|
||||
|
||||
return (
|
||||
<g key={`trigger-edge-${triggerIdx}`}>
|
||||
<path d={d} fill="none" stroke={chrome.edge} strokeWidth={1.2} strokeDasharray="4 3" />
|
||||
<polygon
|
||||
points={`${targetX - 3},${targetY - 5} ${targetX + 3},${targetY - 5} ${targetX},${targetY - 1}`}
|
||||
fill={chrome.edgeArrow}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNode = (node: DraftNode, i: number) => {
|
||||
const pos = nodePos(i);
|
||||
const isHovered = hoveredNode === node.id;
|
||||
@@ -994,7 +1136,7 @@ export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchar
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${Math.max((maxContentRight ?? 0), groupBoxMaxX) + (backEdgeOverflow ?? 0)} ${totalH}`}
|
||||
viewBox={`0 0 ${Math.max((maxContentRight ?? 0), groupBoxMaxX, triggerNodes.length > 0 ? triggerPillX(triggerNodes.length - 1) + triggerW : 0) + (backEdgeOverflow ?? 0)} ${totalH}`}
|
||||
preserveAspectRatio="xMidYMin meet"
|
||||
className="select-none"
|
||||
style={{
|
||||
@@ -1078,6 +1220,11 @@ export default function DraftGraph({ draft, originalDraft, onNodeClick, flowchar
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Trigger edges (dashed lines from trigger pills to first draft node) */}
|
||||
{triggerNodes.map((_, i) => renderTriggerEdge(i))}
|
||||
{/* Trigger pill nodes */}
|
||||
{triggerNodes.map((tn, i) => renderTriggerNode(tn, i))}
|
||||
|
||||
{forwardEdges.map((e, i) => renderEdge(e, i))}
|
||||
{backEdges.map((e, i) => renderBackEdge(e, i))}
|
||||
{nodes.map((n, i) => renderNode(n, i))}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ── Shared graph utilities ──
|
||||
// Common helpers used by both AgentGraph and DraftGraph.
|
||||
// AgentGraph still has its own copies for now (separate cleanup PR).
|
||||
|
||||
/** Read a CSS custom property value (space-separated HSL components). */
|
||||
export function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
/** Truncate label to fit within `availablePx` at the given fontSize. */
|
||||
export function truncateLabel(label: string, availablePx: number, fontSize: number): string {
|
||||
const avgCharW = fontSize * 0.58;
|
||||
const maxChars = Math.floor(availablePx / avgCharW);
|
||||
if (label.length <= maxChars) return label;
|
||||
return label.slice(0, Math.max(maxChars - 1, 1)) + "\u2026";
|
||||
}
|
||||
|
||||
// ── Trigger styling ──
|
||||
|
||||
export type TriggerColorSet = { bg: string; border: string; text: string; icon: string };
|
||||
|
||||
export function buildTriggerColors(): TriggerColorSet {
|
||||
const bg = cssVar("--trigger-bg") || "210 25% 14%";
|
||||
const border = cssVar("--trigger-border") || "210 30% 30%";
|
||||
const text = cssVar("--trigger-text") || "210 30% 65%";
|
||||
const icon = cssVar("--trigger-icon") || "210 40% 55%";
|
||||
return {
|
||||
bg: `hsl(${bg})`,
|
||||
border: `hsl(${border})`,
|
||||
text: `hsl(${text})`,
|
||||
icon: `hsl(${icon})`,
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTIVE_TRIGGER_COLORS: TriggerColorSet = {
|
||||
bg: "hsl(210,30%,18%)",
|
||||
border: "hsl(210,50%,50%)",
|
||||
text: "hsl(210,40%,75%)",
|
||||
icon: "hsl(210,60%,65%)",
|
||||
};
|
||||
|
||||
export const TRIGGER_ICONS: Record<string, string> = {
|
||||
webhook: "\u26A1", // lightning bolt
|
||||
timer: "\u23F1", // stopwatch
|
||||
api: "\u2192", // right arrow
|
||||
event: "\u223F", // sine wave
|
||||
};
|
||||
|
||||
/** Format a cron expression into a human-readable schedule label. */
|
||||
export function cronToLabel(cron: string): string {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return cron;
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
|
||||
// */N * * * * -> "Every Nm"
|
||||
if (min.startsWith("*/") && hour === "*" && dom === "*" && mon === "*" && dow === "*") {
|
||||
return `Every ${min.slice(2)}m`;
|
||||
}
|
||||
// 0 */N * * * -> "Every Nh"
|
||||
if (min === "0" && hour.startsWith("*/") && dom === "*" && mon === "*" && dow === "*") {
|
||||
return `Every ${hour.slice(2)}h`;
|
||||
}
|
||||
// 0 H * * * -> "Daily at Ham/pm"
|
||||
if (dom === "*" && mon === "*" && dow === "*" && !min.includes("*") && !hour.includes("*")) {
|
||||
const h = parseInt(hour, 10);
|
||||
const m = parseInt(min, 10);
|
||||
const suffix = h >= 12 ? "PM" : "AM";
|
||||
const h12 = h % 12 || 12;
|
||||
return m === 0 ? `Daily at ${h12}${suffix}` : `Daily at ${h12}:${String(m).padStart(2, "0")}${suffix}`;
|
||||
}
|
||||
return cron;
|
||||
}
|
||||
|
||||
/** Theme-reactive hook for inactive trigger colors. */
|
||||
export function useTriggerColors(): TriggerColorSet {
|
||||
const [colors, setColors] = useState<TriggerColorSet>(buildTriggerColors);
|
||||
|
||||
useEffect(() => {
|
||||
const rebuild = () => setColors(buildTriggerColors());
|
||||
const obs = new MutationObserver(rebuild);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return colors;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useMultiSSE } from "@/hooks/use-sse";
|
||||
import type { LiveSession, AgentEvent, DiscoverEntry, NodeSpec, DraftGraph as DraftGraphData } from "@/api/types";
|
||||
import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
|
||||
import { topologyToGraphNodes } from "@/lib/graph-converter";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
||||
const makeId = () => Math.random().toString(36).slice(2, 9);
|
||||
@@ -557,7 +558,11 @@ export default function Workspace() {
|
||||
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [triggerTaskDraft, setTriggerTaskDraft] = useState("");
|
||||
const [triggerCronDraft, setTriggerCronDraft] = useState("");
|
||||
const [triggerTaskSaving, setTriggerTaskSaving] = useState(false);
|
||||
const [triggerScheduleSaving, setTriggerScheduleSaving] = useState(false);
|
||||
const [triggerCronSaved, setTriggerCronSaved] = useState(false);
|
||||
const [triggerTaskSaved, setTriggerTaskSaved] = useState(false);
|
||||
const [newTabOpen, setNewTabOpen] = useState(false);
|
||||
const newTabBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [graphPanelPct, setGraphPanelPct] = useState(30);
|
||||
@@ -1260,12 +1265,28 @@ export default function Workspace() {
|
||||
|
||||
const fireMap = new Map<string, number>();
|
||||
const taskMap = new Map<string, string>();
|
||||
const labelMap = new Map<string, string>();
|
||||
const targetMap = new Map<string, string>();
|
||||
for (const ep of triggerEps) {
|
||||
const nodeId = `__trigger_${ep.id}`;
|
||||
if (ep.next_fire_in != null) {
|
||||
fireMap.set(`__trigger_${ep.id}`, ep.next_fire_in);
|
||||
fireMap.set(nodeId, ep.next_fire_in);
|
||||
}
|
||||
if (ep.task != null) {
|
||||
taskMap.set(`__trigger_${ep.id}`, ep.task);
|
||||
taskMap.set(nodeId, ep.task);
|
||||
}
|
||||
const cron = ep.trigger_config?.cron as string | undefined;
|
||||
const interval = ep.trigger_config?.interval_minutes as number | undefined;
|
||||
const epLabel = cron
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: ep.name || undefined;
|
||||
if (epLabel) {
|
||||
labelMap.set(nodeId, epLabel);
|
||||
}
|
||||
if (ep.entry_node) {
|
||||
targetMap.set(nodeId, ep.entry_node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,14 +1295,18 @@ export default function Workspace() {
|
||||
if (!ss?.length) return prev;
|
||||
const existingIds = new Set(ss[0].graphNodes.map(n => n.id));
|
||||
|
||||
// Update existing trigger nodes
|
||||
// Update existing trigger nodes (countdown, task, label, target)
|
||||
let updated = ss[0].graphNodes.map((n) => {
|
||||
if (n.nodeType !== "trigger") return n;
|
||||
const nfi = fireMap.get(n.id);
|
||||
const task = taskMap.get(n.id);
|
||||
if (nfi == null && task == null) return n;
|
||||
const label = labelMap.get(n.id);
|
||||
const target = targetMap.get(n.id);
|
||||
if (nfi == null && task == null && !label && !target) return n;
|
||||
return {
|
||||
...n,
|
||||
...(label && label !== n.label ? { label } : {}),
|
||||
...(target ? { next: [target] } : {}),
|
||||
triggerConfig: {
|
||||
...n.triggerConfig,
|
||||
...(nfi != null ? { next_fire_in: nfi } : {}),
|
||||
@@ -1291,14 +1316,15 @@ export default function Workspace() {
|
||||
});
|
||||
|
||||
// Discover new triggers not yet in the graph
|
||||
const entryNode = ss[0].graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const fallbackEntry = ss[0].graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const newNodes: GraphNode[] = [];
|
||||
for (const ep of triggerEps) {
|
||||
const nodeId = `__trigger_${ep.id}`;
|
||||
if (existingIds.has(nodeId)) continue;
|
||||
const target = ep.entry_node || fallbackEntry;
|
||||
newNodes.push({
|
||||
id: nodeId,
|
||||
label: ep.name || ep.id,
|
||||
label: labelMap.get(nodeId) || ep.name || ep.id,
|
||||
status: "pending",
|
||||
nodeType: "trigger",
|
||||
triggerType: ep.trigger_type,
|
||||
@@ -1307,7 +1333,7 @@ export default function Workspace() {
|
||||
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
|
||||
...(ep.task ? { task: ep.task } : {}),
|
||||
},
|
||||
...(entryNode ? { next: [entryNode] } : {}),
|
||||
...(target ? { next: [target] } : {}),
|
||||
});
|
||||
}
|
||||
if (newNodes.length > 0) {
|
||||
@@ -2237,10 +2263,18 @@ export default function Workspace() {
|
||||
// Synthesize new trigger node at the front of the graph
|
||||
const triggerType = (event.data?.trigger_type as string) || "timer";
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const entryNode = s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const triggerName = (event.data?.name as string) || triggerId;
|
||||
const _cron = triggerConfig.cron as string | undefined;
|
||||
const _interval = triggerConfig.interval_minutes as number | undefined;
|
||||
const computedLabel = _cron
|
||||
? cronToLabel(_cron)
|
||||
: _interval
|
||||
? `Every ${_interval >= 60 ? `${_interval / 60}h` : `${_interval}m`}`
|
||||
: triggerName;
|
||||
const newNode: GraphNode = {
|
||||
id: nodeId,
|
||||
label: triggerId,
|
||||
label: computedLabel,
|
||||
status: "running",
|
||||
nodeType: "trigger",
|
||||
triggerType,
|
||||
@@ -2305,10 +2339,18 @@ export default function Workspace() {
|
||||
if (s.graphNodes.some(n => n.id === nodeId)) return s;
|
||||
const triggerType = (event.data?.trigger_type as string) || "timer";
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const entryNode = s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
|
||||
const triggerName = (event.data?.name as string) || triggerId;
|
||||
const _cron2 = triggerConfig.cron as string | undefined;
|
||||
const _interval2 = triggerConfig.interval_minutes as number | undefined;
|
||||
const computedLabel2 = _cron2
|
||||
? cronToLabel(_cron2)
|
||||
: _interval2
|
||||
? `Every ${_interval2 >= 60 ? `${_interval2 / 60}h` : `${_interval2}m`}`
|
||||
: triggerName;
|
||||
const newNode: GraphNode = {
|
||||
id: nodeId,
|
||||
label: triggerId,
|
||||
label: computedLabel2,
|
||||
status: "pending",
|
||||
nodeType: "trigger",
|
||||
triggerType,
|
||||
@@ -2323,6 +2365,43 @@ export default function Workspace() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "trigger_updated": {
|
||||
const triggerId = event.data?.trigger_id as string;
|
||||
if (triggerId) {
|
||||
const nodeId = `__trigger_${triggerId}`;
|
||||
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
|
||||
const cron = triggerConfig.cron as string | undefined;
|
||||
const interval = triggerConfig.interval_minutes as number | undefined;
|
||||
const newLabel = cron
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: undefined;
|
||||
setSessionsByAgent(prev => {
|
||||
const sessions = prev[agentType] || [];
|
||||
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
|
||||
return {
|
||||
...prev,
|
||||
[agentType]: sessions.map(s => {
|
||||
if (s.id !== activeId) return s;
|
||||
return {
|
||||
...s,
|
||||
graphNodes: s.graphNodes.map(n => {
|
||||
if (n.id !== nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
...(newLabel ? { label: newLabel } : {}),
|
||||
triggerConfig: { ...n.triggerConfig, ...triggerConfig },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "trigger_removed": {
|
||||
const triggerId = event.data?.trigger_id as string;
|
||||
if (triggerId) {
|
||||
@@ -2376,14 +2455,43 @@ export default function Workspace() {
|
||||
const liveSelectedNode = selectedNode && currentGraph.nodes.find(n => n.id === selectedNode.id);
|
||||
const resolvedSelectedNode = liveSelectedNode || selectedNode;
|
||||
|
||||
// Sync trigger task draft when selected trigger node changes
|
||||
// Sync trigger drafts when selected trigger node changes
|
||||
useEffect(() => {
|
||||
if (resolvedSelectedNode?.nodeType === "trigger") {
|
||||
const tc = resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined;
|
||||
setTriggerTaskDraft((tc?.task as string) || "");
|
||||
setTriggerCronDraft((tc?.cron as string) || "");
|
||||
}
|
||||
}, [resolvedSelectedNode?.id]);
|
||||
|
||||
const patchTriggerNode = useCallback((agentType: string, triggerNodeId: string, patch: { task?: string; trigger_config?: Record<string, unknown>; label?: string }) => {
|
||||
setSessionsByAgent(prev => {
|
||||
const sessions = prev[agentType] || [];
|
||||
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
|
||||
return {
|
||||
...prev,
|
||||
[agentType]: sessions.map(s => {
|
||||
if (s.id !== activeId) return s;
|
||||
return {
|
||||
...s,
|
||||
graphNodes: s.graphNodes.map(n => {
|
||||
if (n.id !== triggerNodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
...(patch.label !== undefined ? { label: patch.label } : {}),
|
||||
triggerConfig: {
|
||||
...n.triggerConfig,
|
||||
...(patch.trigger_config || {}),
|
||||
...(patch.task !== undefined ? { task: patch.task } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build a flat list of all agent-type tabs for the tab bar
|
||||
const agentTabs = Object.entries(sessionsByAgent)
|
||||
.filter(([, sessions]) => sessions.length > 0)
|
||||
@@ -3052,18 +3160,64 @@ export default function Workspace() {
|
||||
const interval = tc?.interval_minutes as number | undefined;
|
||||
const eventTypes = tc?.event_types as string[] | undefined;
|
||||
const scheduleLabel = cron
|
||||
? `cron: ${cron}`
|
||||
? cronToLabel(cron)
|
||||
: interval
|
||||
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
|
||||
: eventTypes?.length
|
||||
? eventTypes.join(", ")
|
||||
: null;
|
||||
return scheduleLabel ? (
|
||||
const canEditCron = resolvedSelectedNode.triggerType === "timer";
|
||||
const cronChanged = canEditCron && triggerCronDraft.trim() !== (cron || "");
|
||||
return scheduleLabel || canEditCron ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Schedule</p>
|
||||
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
|
||||
{scheduleLabel}
|
||||
</p>
|
||||
{scheduleLabel && (
|
||||
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
|
||||
{scheduleLabel}
|
||||
</p>
|
||||
)}
|
||||
{canEditCron && (
|
||||
<>
|
||||
<input
|
||||
value={triggerCronDraft}
|
||||
onChange={(e) => setTriggerCronDraft(e.target.value)}
|
||||
placeholder="0 5 * * *"
|
||||
className="mt-1.5 w-full text-xs text-foreground/80 bg-muted/30 rounded-lg px-3 py-2 border border-border/20 font-mono focus:outline-none focus:border-primary/40"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
||||
Edit the cron expression for this timer trigger.
|
||||
</p>
|
||||
{(cronChanged || triggerCronSaved) && (
|
||||
<button
|
||||
disabled={triggerScheduleSaving || !cronChanged}
|
||||
onClick={async () => {
|
||||
const sessionId = activeAgentState?.sessionId;
|
||||
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
|
||||
const nextCron = triggerCronDraft.trim();
|
||||
if (!sessionId || !nextCron) return;
|
||||
const nextTriggerConfig: Record<string, unknown> = { cron: nextCron };
|
||||
setTriggerScheduleSaving(true);
|
||||
try {
|
||||
await sessionsApi.updateTrigger(sessionId, triggerId, {
|
||||
trigger_config: nextTriggerConfig,
|
||||
});
|
||||
patchTriggerNode(activeWorker, resolvedSelectedNode.id, {
|
||||
trigger_config: nextTriggerConfig,
|
||||
label: cronToLabel(nextCron),
|
||||
});
|
||||
setTriggerCronSaved(true);
|
||||
setTimeout(() => setTriggerCronSaved(false), 2000);
|
||||
} finally {
|
||||
setTriggerScheduleSaving(false);
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{triggerScheduleSaving ? "Saving..." : triggerCronSaved ? "Saved" : "Save Cron"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
@@ -3090,24 +3244,27 @@ export default function Workspace() {
|
||||
{(() => {
|
||||
const currentTask = (resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined)?.task as string || "";
|
||||
const hasChanged = triggerTaskDraft !== currentTask;
|
||||
if (!hasChanged) return null;
|
||||
if (!hasChanged && !triggerTaskSaved) return null;
|
||||
return (
|
||||
<button
|
||||
disabled={triggerTaskSaving}
|
||||
disabled={triggerTaskSaving || !hasChanged}
|
||||
onClick={async () => {
|
||||
const sessionId = activeAgentState?.sessionId;
|
||||
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
|
||||
if (!sessionId) return;
|
||||
setTriggerTaskSaving(true);
|
||||
try {
|
||||
await sessionsApi.updateTriggerTask(sessionId, triggerId, triggerTaskDraft);
|
||||
await sessionsApi.updateTrigger(sessionId, triggerId, { task: triggerTaskDraft });
|
||||
patchTriggerNode(activeWorker, resolvedSelectedNode.id, { task: triggerTaskDraft });
|
||||
setTriggerTaskSaved(true);
|
||||
setTimeout(() => setTriggerTaskSaved(false), 2000);
|
||||
} finally {
|
||||
setTriggerTaskSaving(false);
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{triggerTaskSaving ? "Saving..." : "Save Task"}
|
||||
{triggerTaskSaving ? "Saving..." : triggerTaskSaved ? "Saved" : "Save Task"}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Tests for safe_eval — the sandboxed expression evaluator used by edge conditions.
|
||||
|
||||
Covers: literals, data structures, arithmetic, comparisons, boolean logic
|
||||
(including short-circuit semantics), variable lookup, subscript/attribute
|
||||
access, whitelisted function calls, method calls, ternary expressions,
|
||||
chained comparisons, and security boundaries (private attrs, disallowed
|
||||
AST nodes, disallowed function calls).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.graph.safe_eval import safe_eval
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Literals and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLiterals:
|
||||
def test_integer(self):
|
||||
assert safe_eval("42") == 42
|
||||
|
||||
def test_negative_integer(self):
|
||||
assert safe_eval("-1") == -1
|
||||
|
||||
def test_float(self):
|
||||
assert safe_eval("3.14") == pytest.approx(3.14)
|
||||
|
||||
def test_string(self):
|
||||
assert safe_eval("'hello'") == "hello"
|
||||
|
||||
def test_double_quoted_string(self):
|
||||
assert safe_eval('"world"') == "world"
|
||||
|
||||
def test_boolean_true(self):
|
||||
assert safe_eval("True") is True
|
||||
|
||||
def test_boolean_false(self):
|
||||
assert safe_eval("False") is False
|
||||
|
||||
def test_none(self):
|
||||
assert safe_eval("None") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataStructures:
|
||||
def test_list(self):
|
||||
assert safe_eval("[1, 2, 3]") == [1, 2, 3]
|
||||
|
||||
def test_empty_list(self):
|
||||
assert safe_eval("[]") == []
|
||||
|
||||
def test_nested_list(self):
|
||||
assert safe_eval("[[1, 2], [3, 4]]") == [[1, 2], [3, 4]]
|
||||
|
||||
def test_tuple(self):
|
||||
assert safe_eval("(1, 2, 3)") == (1, 2, 3)
|
||||
|
||||
def test_dict(self):
|
||||
assert safe_eval("{'a': 1, 'b': 2}") == {"a": 1, "b": 2}
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert safe_eval("{}") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arithmetic and binary operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArithmetic:
|
||||
def test_addition(self):
|
||||
assert safe_eval("2 + 3") == 5
|
||||
|
||||
def test_subtraction(self):
|
||||
assert safe_eval("10 - 4") == 6
|
||||
|
||||
def test_multiplication(self):
|
||||
assert safe_eval("3 * 7") == 21
|
||||
|
||||
def test_division(self):
|
||||
assert safe_eval("10 / 4") == 2.5
|
||||
|
||||
def test_floor_division(self):
|
||||
assert safe_eval("10 // 3") == 3
|
||||
|
||||
def test_modulo(self):
|
||||
assert safe_eval("10 % 3") == 1
|
||||
|
||||
def test_power(self):
|
||||
assert safe_eval("2 ** 10") == 1024
|
||||
|
||||
def test_complex_expression(self):
|
||||
assert safe_eval("(2 + 3) * 4 - 1") == 19
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unary operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnaryOps:
|
||||
def test_negation(self):
|
||||
assert safe_eval("-5") == -5
|
||||
|
||||
def test_positive(self):
|
||||
assert safe_eval("+5") == 5
|
||||
|
||||
def test_not_true(self):
|
||||
assert safe_eval("not True") is False
|
||||
|
||||
def test_not_false(self):
|
||||
assert safe_eval("not False") is True
|
||||
|
||||
def test_bitwise_invert(self):
|
||||
assert safe_eval("~0") == -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparisons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComparisons:
|
||||
def test_equal(self):
|
||||
assert safe_eval("1 == 1") is True
|
||||
|
||||
def test_not_equal(self):
|
||||
assert safe_eval("1 != 2") is True
|
||||
|
||||
def test_less_than(self):
|
||||
assert safe_eval("1 < 2") is True
|
||||
|
||||
def test_greater_than(self):
|
||||
assert safe_eval("2 > 1") is True
|
||||
|
||||
def test_less_equal(self):
|
||||
assert safe_eval("2 <= 2") is True
|
||||
|
||||
def test_greater_equal(self):
|
||||
assert safe_eval("3 >= 2") is True
|
||||
|
||||
def test_is_none(self):
|
||||
assert safe_eval("x is None", {"x": None}) is True
|
||||
|
||||
def test_is_not_none(self):
|
||||
assert safe_eval("x is not None", {"x": 42}) is True
|
||||
|
||||
def test_in_list(self):
|
||||
assert safe_eval("'a' in x", {"x": ["a", "b", "c"]}) is True
|
||||
|
||||
def test_not_in_list(self):
|
||||
assert safe_eval("'z' not in x", {"x": ["a", "b"]}) is True
|
||||
|
||||
def test_chained_comparison(self):
|
||||
"""Chained comparisons like 1 < x < 10 should work."""
|
||||
assert safe_eval("1 < x < 10", {"x": 5}) is True
|
||||
|
||||
def test_chained_comparison_false(self):
|
||||
assert safe_eval("1 < x < 3", {"x": 5}) is False
|
||||
|
||||
def test_chained_three_way(self):
|
||||
assert safe_eval("0 <= x <= 100", {"x": 50}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boolean operators (with short-circuit semantics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBooleanOps:
|
||||
def test_and_true(self):
|
||||
assert safe_eval("True and True") is True
|
||||
|
||||
def test_and_false(self):
|
||||
assert safe_eval("True and False") is False
|
||||
|
||||
def test_or_true(self):
|
||||
assert safe_eval("False or True") is True
|
||||
|
||||
def test_or_false(self):
|
||||
assert safe_eval("False or False") is False
|
||||
|
||||
def test_and_returns_last_truthy(self):
|
||||
"""Python `and` returns the last value if all truthy."""
|
||||
assert safe_eval("1 and 2 and 3") == 3
|
||||
|
||||
def test_and_returns_first_falsy(self):
|
||||
"""Python `and` returns the first falsy value."""
|
||||
assert safe_eval("1 and 0 and 3") == 0
|
||||
|
||||
def test_or_returns_first_truthy(self):
|
||||
"""Python `or` returns the first truthy value."""
|
||||
assert safe_eval("0 or '' or 42") == 42
|
||||
|
||||
def test_or_returns_last_falsy(self):
|
||||
"""Python `or` returns the last value if all falsy."""
|
||||
assert safe_eval("0 or '' or None") is None
|
||||
|
||||
def test_and_short_circuits(self):
|
||||
"""and should NOT evaluate the right side if left is falsy.
|
||||
|
||||
This is the bug we fixed — previously this would crash with
|
||||
TypeError because all operands were eagerly evaluated.
|
||||
"""
|
||||
# x is None, so `x.get("key")` would crash if evaluated
|
||||
assert safe_eval("x is not None and x.get('key')", {"x": None}) is False
|
||||
|
||||
def test_or_short_circuits(self):
|
||||
"""or should NOT evaluate the right side if left is truthy."""
|
||||
# x is truthy, so the crash-prone right side should never run
|
||||
assert safe_eval("x or y.get('missing')", {"x": "found", "y": {}}) == "found"
|
||||
|
||||
def test_and_guard_pattern_truthy(self):
|
||||
"""Guard pattern: check not None, then access — when value exists."""
|
||||
ctx = {"x": {"key": "value"}}
|
||||
assert safe_eval("x is not None and x.get('key')", ctx) == "value"
|
||||
|
||||
def test_multi_and(self):
|
||||
assert safe_eval("True and True and True") is True
|
||||
|
||||
def test_multi_or(self):
|
||||
assert safe_eval("False or False or True") is True
|
||||
|
||||
def test_mixed_and_or(self):
|
||||
assert safe_eval("True or False and False") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ternary (if/else) expressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTernary:
|
||||
def test_ternary_true_branch(self):
|
||||
assert safe_eval("'yes' if True else 'no'") == "yes"
|
||||
|
||||
def test_ternary_false_branch(self):
|
||||
assert safe_eval("'yes' if False else 'no'") == "no"
|
||||
|
||||
def test_ternary_with_context(self):
|
||||
assert safe_eval("x * 2 if x > 0 else -x", {"x": 5}) == 10
|
||||
|
||||
def test_ternary_false_with_context(self):
|
||||
assert safe_eval("x * 2 if x > 0 else -x", {"x": -3}) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variable lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVariables:
|
||||
def test_simple_variable(self):
|
||||
assert safe_eval("x", {"x": 42}) == 42
|
||||
|
||||
def test_string_variable(self):
|
||||
assert safe_eval("name", {"name": "Alice"}) == "Alice"
|
||||
|
||||
def test_dict_variable(self):
|
||||
ctx = {"output": {"status": "ok"}}
|
||||
assert safe_eval("output", ctx) == {"status": "ok"}
|
||||
|
||||
def test_undefined_variable_raises(self):
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("undefined_var")
|
||||
|
||||
def test_multiple_variables(self):
|
||||
assert safe_eval("x + y", {"x": 10, "y": 20}) == 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subscript access (indexing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubscript:
|
||||
def test_dict_subscript(self):
|
||||
assert safe_eval("d['key']", {"d": {"key": "value"}}) == "value"
|
||||
|
||||
def test_list_subscript(self):
|
||||
assert safe_eval("items[0]", {"items": [10, 20, 30]}) == 10
|
||||
|
||||
def test_nested_subscript(self):
|
||||
ctx = {"data": {"users": [{"name": "Alice"}]}}
|
||||
assert safe_eval("data['users'][0]['name']", ctx) == "Alice"
|
||||
|
||||
def test_missing_key_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
safe_eval("d['missing']", {"d": {}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attribute access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAttributeAccess:
|
||||
def test_private_attr_blocked(self):
|
||||
"""Attributes starting with _ must be blocked for security."""
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x.__class__", {"x": 42})
|
||||
|
||||
def test_dunder_blocked(self):
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x.__dict__", {"x": {}})
|
||||
|
||||
def test_single_underscore_blocked(self):
|
||||
with pytest.raises(ValueError, match="private attribute"):
|
||||
safe_eval("x._internal", {"x": {}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelisted function calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFunctionCalls:
|
||||
def test_len(self):
|
||||
assert safe_eval("len(x)", {"x": [1, 2, 3]}) == 3
|
||||
|
||||
def test_int_conversion(self):
|
||||
assert safe_eval("int('42')") == 42
|
||||
|
||||
def test_float_conversion(self):
|
||||
assert safe_eval("float('3.14')") == pytest.approx(3.14)
|
||||
|
||||
def test_str_conversion(self):
|
||||
assert safe_eval("str(42)") == "42"
|
||||
|
||||
def test_bool_conversion(self):
|
||||
assert safe_eval("bool(1)") is True
|
||||
|
||||
def test_abs(self):
|
||||
assert safe_eval("abs(-5)") == 5
|
||||
|
||||
def test_min(self):
|
||||
assert safe_eval("min(3, 1, 2)") == 1
|
||||
|
||||
def test_max(self):
|
||||
assert safe_eval("max(3, 1, 2)") == 3
|
||||
|
||||
def test_sum(self):
|
||||
assert safe_eval("sum(x)", {"x": [1, 2, 3]}) == 6
|
||||
|
||||
def test_round(self):
|
||||
assert safe_eval("round(3.7)") == 4
|
||||
|
||||
def test_all(self):
|
||||
assert safe_eval("all([True, True, True])") is True
|
||||
|
||||
def test_any(self):
|
||||
assert safe_eval("any([False, False, True])") is True
|
||||
|
||||
def test_list_constructor(self):
|
||||
assert safe_eval("list(x)", {"x": (1, 2, 3)}) == [1, 2, 3]
|
||||
|
||||
def test_dict_constructor(self):
|
||||
assert safe_eval("dict(a=1, b=2)") == {"a": 1, "b": 2}
|
||||
|
||||
def test_tuple_constructor(self):
|
||||
assert safe_eval("tuple(x)", {"x": [1, 2]}) == (1, 2)
|
||||
|
||||
def test_set_constructor(self):
|
||||
assert safe_eval("set(x)", {"x": [1, 2, 2, 3]}) == {1, 2, 3}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelisted method calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMethodCalls:
|
||||
def test_dict_get(self):
|
||||
assert safe_eval("d.get('key', 'default')", {"d": {"key": "val"}}) == "val"
|
||||
|
||||
def test_dict_get_missing(self):
|
||||
assert safe_eval("d.get('missing', 'default')", {"d": {}}) == "default"
|
||||
|
||||
def test_dict_keys(self):
|
||||
result = safe_eval("list(d.keys())", {"d": {"a": 1, "b": 2}})
|
||||
assert sorted(result) == ["a", "b"]
|
||||
|
||||
def test_dict_values(self):
|
||||
result = safe_eval("list(d.values())", {"d": {"a": 1, "b": 2}})
|
||||
assert sorted(result) == [1, 2]
|
||||
|
||||
def test_string_lower(self):
|
||||
assert safe_eval("s.lower()", {"s": "HELLO"}) == "hello"
|
||||
|
||||
def test_string_upper(self):
|
||||
assert safe_eval("s.upper()", {"s": "hello"}) == "HELLO"
|
||||
|
||||
def test_string_strip(self):
|
||||
assert safe_eval("s.strip()", {"s": " hi "}) == "hi"
|
||||
|
||||
def test_string_split(self):
|
||||
assert safe_eval("s.split(',')", {"s": "a,b,c"}) == ["a", "b", "c"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security: disallowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecurity:
|
||||
def test_import_blocked(self):
|
||||
"""__import__ is not in context, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("__import__('os')")
|
||||
|
||||
def test_lambda_blocked(self):
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
safe_eval("(lambda: 1)()")
|
||||
|
||||
def test_comprehension_blocked(self):
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
safe_eval("[x for x in range(10)]")
|
||||
|
||||
def test_assignment_blocked(self):
|
||||
"""Assignment expressions should not parse in eval mode."""
|
||||
with pytest.raises(SyntaxError):
|
||||
safe_eval("x = 5")
|
||||
|
||||
def test_disallowed_function_blocked(self):
|
||||
"""eval is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("eval('1+1')")
|
||||
|
||||
def test_exec_blocked(self):
|
||||
"""exec is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("exec('x=1')")
|
||||
|
||||
def test_type_call_blocked(self):
|
||||
"""type is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("type(42)")
|
||||
|
||||
def test_getattr_builtin_blocked(self):
|
||||
"""getattr is not in safe functions, so NameError is raised."""
|
||||
with pytest.raises(NameError, match="not defined"):
|
||||
safe_eval("getattr(x, '__class__')", {"x": 42})
|
||||
|
||||
def test_empty_expression_raises(self):
|
||||
with pytest.raises(SyntaxError):
|
||||
safe_eval("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-world edge condition patterns (from graph executor usage)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeConditionPatterns:
|
||||
"""Patterns commonly used in EdgeSpec.condition_expr."""
|
||||
|
||||
def test_output_key_exists_and_not_none(self):
|
||||
ctx = {"output": {"approved_contacts": ["alice@example.com"]}}
|
||||
assert safe_eval("output.get('approved_contacts') is not None", ctx) is True
|
||||
|
||||
def test_output_key_missing(self):
|
||||
ctx = {"output": {}}
|
||||
assert safe_eval("output.get('approved_contacts') is not None", ctx) is False
|
||||
|
||||
def test_output_key_check_with_fallback(self):
|
||||
ctx = {"output": {"redo_extraction": True}}
|
||||
assert safe_eval("output.get('redo_extraction') is not None", ctx) is True
|
||||
|
||||
def test_guard_then_length_check(self):
|
||||
"""Guard pattern: check key exists, then check length."""
|
||||
ctx = {"output": {"results": [1, 2, 3]}}
|
||||
assert (
|
||||
safe_eval(
|
||||
"output.get('results') is not None and len(output['results']) > 0",
|
||||
ctx,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_guard_short_circuits_on_none(self):
|
||||
"""Guard pattern: short-circuit prevents crash on None."""
|
||||
ctx = {"output": {}}
|
||||
assert (
|
||||
safe_eval(
|
||||
"output.get('results') is not None and len(output['results']) > 0",
|
||||
ctx,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_success_flag_check(self):
|
||||
ctx = {"output": {"success": True}, "memory": {"attempts": 2}}
|
||||
assert safe_eval("output.get('success') == True", ctx) is True
|
||||
|
||||
def test_memory_threshold(self):
|
||||
ctx = {"memory": {"score": 0.85}}
|
||||
assert safe_eval("memory.get('score', 0) >= 0.8", ctx) is True
|
||||
|
||||
def test_string_contains_check(self):
|
||||
ctx = {"output": {"status": "completed_with_warnings"}}
|
||||
assert safe_eval("'completed' in output.get('status', '')", ctx) is True
|
||||
|
||||
def test_fallback_chain(self):
|
||||
"""or-chain for fallback values."""
|
||||
ctx = {"output": {}}
|
||||
result = safe_eval(
|
||||
"output.get('primary') or output.get('secondary') or 'default'",
|
||||
ctx,
|
||||
)
|
||||
assert result == "default"
|
||||
|
||||
def test_no_context_needed(self):
|
||||
"""Some edges use constant expressions."""
|
||||
assert safe_eval("True") is True
|
||||
assert safe_eval("1 == 1") is True
|
||||
Reference in New Issue
Block a user