diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 146d78cf..15d84164 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -55,9 +55,9 @@ def _resolve_system_prompt(wf: Workflow) -> Optional[str]: return wf.system_prompt or None -def _resolve_allowed_tools(wf: Workflow) -> list[str]: +def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]: if not wf.actions.freeze: - return [] + return None return list(wf.actions.configured_sets) @@ -221,13 +221,14 @@ async def execute( if not steps: raise ValueError("Workflow has no steps") + resolved_allowed_tools = _resolve_allowed_tools(wf) config = AgentConfig( name=wf.title or "Workflow", model=wf.model or "sonnet", mode=wf.mode or "agent", provider=wf.provider or "anthropic", system_prompt=_resolve_system_prompt(wf), - allowed_tools=_resolve_allowed_tools(wf) or [ + allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], dashboard_id=wf.dashboard_id, diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 420cde9a..f5cfae7a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -128,12 +128,13 @@ def _collect_tool_names_from_content(content, out: set[str]) -> None: _collect_tool_names_from_content(nested, out) -def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str]]: +def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str], Optional[list[str]]]: if not session_id: - return {}, [] + return {}, [], None try: from backend.apps.agents.agent_manager import agent_manager sess = agent_manager.sessions.get(session_id) + allowed_tools = list(getattr(sess, "allowed_tools", [])) if sess is not None else None decisions = getattr(sess, "approval_decisions", None) if sess is not None else None messages = getattr(sess, "messages", None) if sess is not None else None tool_latencies = getattr(sess, "tool_latencies", None) if sess is not None else None @@ -143,8 +144,10 @@ def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], decisions = data.get("approval_decisions") or [] messages = data.get("messages") or [] tool_latencies = data.get("tool_latencies") or {} + raw_allowed = data.get("allowed_tools") + allowed_tools = list(raw_allowed) if isinstance(raw_allowed, list) else None except Exception: - return {}, [] + return {}, [], None approvals: dict[str, str] = {} tools: set[str] = set() for entry in decisions or []: @@ -172,7 +175,7 @@ def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], if name: tools.add(name) _collect_tool_names_from_content(content, tools) - return approvals, sorted(tools) + return approvals, sorted(tools), allowed_tools def p_prune_step_tool_usage(wf: Workflow) -> None: @@ -196,11 +199,26 @@ async def list_workflows(dashboard_id: Optional[str] = None): return {"workflows": [_enriched(w) for w in items]} -def _normalize_schedule_state(wf: Workflow) -> None: +def _normalize_schedule_state(wf: Workflow, source_allowed_tools: Optional[list[str]] = None) -> None: if wf.schedule.timezone == "local" and wf.schedule.enabled: wf.schedule.timezone = scheduler.host_timezone_name() if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule): wf.schedule.enabled = False + if wf.schedule.enabled and wf.schedule.repeat_unit == "month" and wf.schedule.day_of_month is None: + tz = scheduler._resolve_tz(wf.schedule.timezone) + wf.schedule.day_of_month = datetime.now(timezone.utc).astimezone(tz).day + if wf.schedule.enabled and scheduler.is_schedule_configured(wf.schedule) and not wf.actions.freeze: + if wf.source_session_id: + allowed = source_allowed_tools + if allowed is None: + _, _, allowed = p_source_session_memory(wf.source_session_id) + if allowed is not None: + wf.actions = wf.actions.model_copy(update={ + "freeze": True, + "configured_sets": list(allowed), + }) + else: + wf.actions = wf.actions.model_copy(update={"freeze": True}) wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None @@ -226,13 +244,6 @@ async def create_workflow(body: WorkflowCreate): if not body.unsaved and not _has_nonempty_steps(body.steps): raise HTTPException(status_code=400, detail="Workflow must have at least one step") actions = body.actions - # Scheduled workflows default to freeze=on for safety. The user can - # flip "Full agent access" in the editor with an explicit confirm. - # Source-session creates inherit the chat's tool choices so we leave - # them alone there (the source session itself already vetted the - # blast radius). - if body.schedule.enabled and scheduler.is_schedule_configured(body.schedule) and not actions.freeze and not body.source_session_id: - actions = actions.model_copy(update={"freeze": True}) wf = Workflow( title=body.title, description=body.description, @@ -252,7 +263,7 @@ async def create_workflow(body: WorkflowCreate): auto_named=body.auto_named, unsaved=body.unsaved, ) - source_approvals, source_tools = p_source_session_memory(body.source_session_id) + source_approvals, source_tools, source_allowed_tools = p_source_session_memory(body.source_session_id) wf.remembered_approvals = source_approvals wf.source_tools = source_tools # Convert-from-chat passes the steps signature so the workflow counts as @@ -261,7 +272,7 @@ async def create_workflow(body: WorkflowCreate): wf.tested_signature = body.tested_signature if not wf.icon: wf.icon = _derive_icon(wf) - _normalize_schedule_state(wf) + _normalize_schedule_state(wf, source_allowed_tools=source_allowed_tools) # Force-generate title + description + per-step labels from the steps # in a single aux call. Previously we only filled missing description, # leaving stale session names ("Inbox check") as titles. Step labels @@ -1061,13 +1072,14 @@ async def test_run_workflow(workflow_id: str, body: dict): ) from backend.apps.workflows import executor + resolved_allowed_tools = executor._resolve_allowed_tools(wf) config = AgentConfig( name=f"{wf.title or 'Workflow'} (test)", model=wf.model or "sonnet", mode=wf.mode or "agent", provider=wf.provider or "anthropic", system_prompt=executor._resolve_system_prompt(wf), - allowed_tools=executor._resolve_allowed_tools(wf) or [ + allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], dashboard_id=wf.dashboard_id, @@ -1192,6 +1204,7 @@ async def schedule_agent_session(workflow_id: str): " - repeat_every: the interval count (1 unless they say e.g. \"every other\"; " "for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n" " - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n" + " - day_of_month: 1-31 when repeat_unit=\"month\" (1 for \"first of the month\")\n" f" - timezone: \"{local_tz}\" unless the user names a different specific zone\n\n" "If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence " "is genuinely ambiguous, ask ONE short clarifying question first; otherwise " diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py index a4b53d3e..04d4ab49 100644 --- a/backend/tests/test_workflows_semantics.py +++ b/backend/tests/test_workflows_semantics.py @@ -26,6 +26,7 @@ import shutil import sys import tempfile from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from zoneinfo import ZoneInfo import pytest @@ -189,6 +190,110 @@ def test_month_repeat_no_longer_clamps_to_28(): assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date() +def test_month_repeat_can_pin_first_day(): + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + tz = ZoneInfo("America/Los_Angeles") + sched = ScheduleConfig( + enabled=True, + repeat_unit="month", + repeat_every=1, + day_of_month=1, + hour=9, + minute=0, + timezone="America/Los_Angeles", + ) + ref_local = datetime(2025, 6, 20, 10, 0, tzinfo=tz) + nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc)) + assert nxt.astimezone(tz).date() == datetime(2025, 7, 1).date() + + +def test_month_repeat_every_respects_interval_after_clamped_day(): + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + sched = ScheduleConfig( + enabled=True, + repeat_unit="month", + repeat_every=2, + day_of_month=31, + hour=9, + minute=0, + timezone="UTC", + ) + nxt = _next_fire_after(sched, datetime(2025, 1, 31, 9, 0, tzinfo=timezone.utc)) + assert nxt == datetime(2025, 3, 31, 9, 0, tzinfo=timezone.utc) + + +def test_monthly_create_pins_missing_day_to_creation_day(monkeypatch): + from backend.apps.workflows import workflows as workflows_mod + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, WorkflowStep + from backend.apps.workflows.scheduler import _next_fire_after + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + base = datetime(2025, 3, 31, 10, 0, tzinfo=timezone.utc) + return base.astimezone(tz) if tz is not None else base.replace(tzinfo=None) + + monkeypatch.setattr(workflows_mod, "datetime", FrozenDateTime) + body = WorkflowCreate( + title="monthly", + steps=[WorkflowStep(text="say hi")], + schedule=ScheduleConfig( + enabled=True, + repeat_unit="month", + repeat_every=1, + hour=9, + minute=0, + timezone="UTC", + ), + ) + result = asyncio.new_event_loop().run_until_complete(workflows_mod.create_workflow(body)) + assert result["schedule"]["day_of_month"] == 31 + + sched = ScheduleConfig(**result["schedule"]) + nxt = _next_fire_after(sched, datetime(2025, 4, 30, 9, 0, tzinfo=timezone.utc)) + assert nxt == datetime(2025, 5, 31, 9, 0, tzinfo=timezone.utc) + + +def test_daily_repeat_every_skips_by_interval(): + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + sched = ScheduleConfig( + enabled=True, + repeat_unit="day", + repeat_every=3, + hour=9, + minute=0, + timezone="UTC", + ) + nxt = _next_fire_after(sched, datetime(2026, 6, 20, 9, 0, tzinfo=timezone.utc)) + assert nxt == datetime(2026, 6, 23, 9, 0, tzinfo=timezone.utc) + + +def test_weekly_repeat_every_skips_inactive_weeks(): + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + sched = ScheduleConfig( + enabled=True, + repeat_unit="week", + repeat_every=2, + on_days=[1], + hour=9, + minute=0, + timezone="UTC", + ) + nxt = _next_fire_after(sched, datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc)) + assert nxt == datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc) + + +def test_frozen_empty_tool_set_does_not_fall_back_to_defaults(): + from backend.apps.workflows.executor import _resolve_allowed_tools + from backend.apps.workflows.models import ActionsConfig + wf = _make_wf(actions=ActionsConfig(freeze=True, configured_sets=[])) + assert _resolve_allowed_tools(wf) == [] + + def test_calendar_occurrences_use_schedule_timezone_not_viewer_timezone(): """A 9am New York schedule returns UTC instants. The frontend can then render those instants in the viewer's current timezone.""" @@ -335,9 +440,10 @@ def test_freeze_defaults_on_for_scheduled_create(): """POST /workflows/create with schedule.enabled=true and no source session should flip actions.freeze=True to keep blast radius small.""" from backend.apps.workflows.workflows import create_workflow - from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep body = WorkflowCreate( title="scheduled", + steps=[WorkflowStep(text="say hi")], schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0), actions=ActionsConfig(freeze=False, configured_sets=[]), ) @@ -348,10 +454,11 @@ def test_freeze_defaults_on_for_scheduled_create(): def test_freeze_not_forced_when_source_session_present(): """Source-session creates inherit the chat's choices; we don't override.""" from backend.apps.workflows.workflows import create_workflow - from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep body = WorkflowCreate( title="from chat", source_session_id="sess-1", + steps=[WorkflowStep(text="say hi")], schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0), actions=ActionsConfig(freeze=False, configured_sets=[]), ) @@ -359,14 +466,40 @@ def test_freeze_not_forced_when_source_session_present(): assert result["actions"]["freeze"] is False +def test_source_session_create_inherits_allowed_tools(): + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.workflows.workflows import create_workflow + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep + agent_manager.sessions["sess-allowed"] = SimpleNamespace( + allowed_tools=["Read"], + approval_decisions=[], + messages=[], + tool_latencies={}, + ) + try: + body = WorkflowCreate( + title="from restricted chat", + source_session_id="sess-allowed", + steps=[WorkflowStep(text="say hi")], + schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0), + actions=ActionsConfig(freeze=False, configured_sets=[]), + ) + result = asyncio.new_event_loop().run_until_complete(create_workflow(body)) + finally: + agent_manager.sessions.pop("sess-allowed", None) + assert result["actions"]["freeze"] is True + assert result["actions"]["configured_sets"] == ["Read"] + + def test_create_enabled_schedule_normalizes_local_timezone(monkeypatch): from backend.apps.workflows import scheduler from backend.apps.workflows.workflows import create_workflow - from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, WorkflowStep monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Chicago") monkeypatch.setattr(scheduler, "_host_tz_cache", None) body = WorkflowCreate( title="local-tz-create", + steps=[WorkflowStep(text="say hi")], schedule=ScheduleConfig( enabled=True, repeat_unit="day",