diff --git a/api/core/workflow_copilot/runner.py b/api/core/workflow_copilot/runner.py index 3b1bc12d2e0..fa1d284075e 100644 --- a/api/core/workflow_copilot/runner.py +++ b/api/core/workflow_copilot/runner.py @@ -95,6 +95,12 @@ class Runner: f"copilot: stale base_version {turn.action.base_version} for session {session_id} (current {s.version})" ) + action_kind = turn.action.kind if turn.action is not None else "" + if action_kind in ("stop", "resume"): + fc.paused = action_kind == "stop" + s.version = self._env.repo.compare_and_advance(s.id, s.version, s.current_state, fc, []) + return s + first = True while True: if is_waiting(s.current_state) and first and turn.action is None: diff --git a/api/services/workflow_copilot/service.py b/api/services/workflow_copilot/service.py index fe356fb1bfb..eee21d8b48e 100644 --- a/api/services/workflow_copilot/service.py +++ b/api/services/workflow_copilot/service.py @@ -240,7 +240,7 @@ def resolve_action_kind(raw: str) -> str: return _ACTION_ID_TO_KIND.get(raw, raw) -def _run_status(state: PcState) -> RunStatus: +def _run_status(state: PcState, paused: bool = False) -> RunStatus: """Port of Go ``runStatusFor``, widened to the ``RunStatus`` enum (spec ยง2). Deliberate wire-value change from the old string: ``waiting-input`` (hyphen) -> ``RunStatus.WAITING_INPUT`` = ``"waiting_input"`` @@ -251,11 +251,17 @@ def _run_status(state: PcState) -> RunStatus: complete``) but are not in ``_WORKING``/``_WAITING`` and are not ``SUCCESS``/``FAILED`` -- without this ordering they'd wrongly fall through to EXECUTING. + + ``paused`` (Task 7, ``fc.paused``) only applies at a waiting state -- the + canvas stays editable while paused -- and never overrides a terminal + outcome (FAILED/COMPLETE win regardless of the flag). """ if state == PcState.FAILED: return RunStatus.FAILED if is_terminal(state): # SUCCESS, BUILD_COMPLETE, EDIT_PUBLISH return RunStatus.COMPLETE + if paused and is_waiting(state): + return RunStatus.PAUSED if is_waiting(state): return RunStatus.WAITING_INPUT if is_working(state): @@ -385,7 +391,7 @@ class WorkflowCopilotService: version=s.version, state=str(st), canvas_read_only=canvas_read_only(st), - run_status=_run_status(st), + run_status=_run_status(st, paused=fc.paused), interrupted=is_working(st) and not self._session_lock.exists(session_id), conversation=items, entry_mode=s.entry_mode, diff --git a/api/tests/unit_tests/core/workflow_copilot/test_lifecycle.py b/api/tests/unit_tests/core/workflow_copilot/test_lifecycle.py index 5ee9bbb16a9..fc8589fd65f 100644 --- a/api/tests/unit_tests/core/workflow_copilot/test_lifecycle.py +++ b/api/tests/unit_tests/core/workflow_copilot/test_lifecycle.py @@ -223,3 +223,42 @@ def test_edit_apply_changes_undo_restores_pre_edit_graph(): assert dify.read_graph("app", _actor())[0] == pre_edit # config edit undone assert res.context.checkpoint_id == "" assert {"event": "revert_checkpoint"} in events + + +def test_runner_stop_sets_paused_same_state_and_bumps_version(): + from datetime import datetime + + from core.workflow_copilot.models import Action, ConversationItem, CopilotContext, EntryMode, Session, Turn + from core.workflow_copilot.placeholder_agent import PlaceholderAgent + from core.workflow_copilot.runner import Env, Runner + from core.workflow_copilot.state import PcState + from tests.unit_tests.core.workflow_copilot.fakes import FakeDifyPort, InMemoryRepository + + repo = InMemoryRepository() + env = Env(dify=FakeDifyPort(), agent=PlaceholderAgent(), repo=repo, now=lambda: datetime.min) + s = Session(app_id="app", tenant_id="t", owner_account_id="a", + entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_REVIEW) + repo.create_session(s, CopilotContext(), [ConversationItem(kind="user", seq=0)]) + runner = Runner(env, {}) # empty registry: stop must not need a handler + + out = runner.advance(s.id, Turn(action=Action(kind="stop", base_version=1), actor=_actor())) + assert out.current_state == PcState.BUILD_REVIEW # unchanged + assert out.version == 2 # committed + _s, fc = repo.get_session(s.id) + assert fc.paused is True + + out = runner.advance(s.id, Turn(action=Action(kind="resume", base_version=2), actor=_actor())) + assert out.current_state == PcState.BUILD_REVIEW + _s, fc = repo.get_session(s.id) + assert fc.paused is False + + +def test_run_status_paused_when_flag_set(): + from core.workflow_copilot.contract import RunStatus + from core.workflow_copilot.state import PcState + from services.workflow_copilot.service import _run_status + + assert _run_status(PcState.BUILD_REVIEW, paused=True) == RunStatus.PAUSED + assert _run_status(PcState.BUILD_REVIEW, paused=False) == RunStatus.WAITING_INPUT + # paused never overrides terminal/failed + assert _run_status(PcState.BUILD_COMPLETE, paused=True) == RunStatus.COMPLETE diff --git a/api/tests/unit_tests/services/workflow_copilot/test_service.py b/api/tests/unit_tests/services/workflow_copilot/test_service.py index 144af843d21..2e3459b7a6f 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_service.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_service.py @@ -633,3 +633,14 @@ def test_get_session_view_no_checkpoint_when_unset( repo.create_session(s, CopilotContext(), [ConversationItem(kind="user", seq=0)]) view = service.get_session_view(s.id, _actor()) assert view.checkpoint is None + + +def test_get_session_view_run_status_paused( + service: WorkflowCopilotService, repo: SqlCopilotRepository +) -> None: + s = Session(app_id=APP_ID, tenant_id=TENANT_ID, owner_account_id=ACCOUNT_ID, + entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_REVIEW) + repo.create_session(s, CopilotContext(paused=True), [ConversationItem(kind="user", seq=0)]) + view = service.get_session_view(s.id, _actor()) + assert view.run_status == "paused" + assert view.canvas_read_only is False # editable while paused