diff --git a/api/controllers/console/workflow_copilot.py b/api/controllers/console/workflow_copilot.py index b0f9acfadd9..98fb333c87f 100644 --- a/api/controllers/console/workflow_copilot.py +++ b/api/controllers/console/workflow_copilot.py @@ -91,6 +91,13 @@ def _create(body, actor: Actor) -> tuple[dict, int]: goal_text=body.get("goal_text", ""), ) ) + elif body.get("scenario") == "edit": + result, status = _respond( + lambda: build_service().create_edit_session( + app_id=body.get("app_id", ""), + actor=actor, + ) + ) else: result, status = _respond( lambda: build_service().create_fix_session( diff --git a/api/services/workflow_copilot/service.py b/api/services/workflow_copilot/service.py index d861e2c6bf2..a3cb901914f 100644 --- a/api/services/workflow_copilot/service.py +++ b/api/services/workflow_copilot/service.py @@ -172,6 +172,40 @@ _ACTIONS_FOR: dict[PcState, list[UiAction]] = { UiAction(id="retry_after_revert", label="Retry", kind=ActionKind.PRIMARY, next_state="build.initial_plan"), ], + # Edit (Slice 3). next_state/canvas_event carry the frozen state-map hints. + PcState.EDIT_CAPABILITY_CHECK: [ + UiAction(id="send_edit_goal", label="Send", kind=ActionKind.PRIMARY, + next_state="edit.impact_analysis"), + ], + PcState.EDIT_IMPACT_ANALYSIS: [ + UiAction(id="submit_edit_rules", label="Submit rules", kind=ActionKind.PRIMARY, + next_state="edit.plan_approval"), + ], + PcState.EDIT_PLAN_APPROVAL: [ + UiAction(id="approve_plan", label="Approve changes", kind=ActionKind.PRIMARY, + next_state="edit.apply_changes", canvas_event="create_checkpoint"), + ], + PcState.EDIT_APPLY_CHANGES: [ + UiAction(id="run_affected_tests", label="Run affected tests", kind=ActionKind.PRIMARY, + next_state="edit.test_affected_paths", canvas_event="start_test_run"), + UiAction(id="revert", label="Revert", kind=ActionKind.DESTRUCTIVE, + next_state="edit.reverted", canvas_event="revert_checkpoint"), + ], + PcState.EDIT_REVIEW: [ + UiAction(id="publish_workflow", label="Publish", kind=ActionKind.PRIMARY, + next_state="edit.publish", canvas_event="publish_workflow"), + UiAction(id="keep_draft", label="Keep draft", kind=ActionKind.SECONDARY, + next_state="edit.publish", canvas_event="cancel_publish"), + UiAction(id="continue_adjusting", label="Continue adjusting", kind=ActionKind.SECONDARY, + next_state="edit.impact_analysis", canvas_event="cancel_publish"), + UiAction(id="view_changes", label="View changes", kind=ActionKind.SECONDARY), + UiAction(id="revert", label="Revert", kind=ActionKind.DESTRUCTIVE, + next_state="edit.reverted", canvas_event="revert_checkpoint"), + ], + PcState.EDIT_REVERTED: [ + UiAction(id="retry_after_revert", label="Retry", kind=ActionKind.PRIMARY, + next_state="edit.plan_approval"), + ], } @@ -315,6 +349,24 @@ class WorkflowCopilotService: self.dispatch(s.id, Action(kind="send_goal", payload={"text": goal_text}, base_version=1), actor) return self.get_session_view(s.id, actor) + def create_edit_session(self, app_id: str, actor: Actor) -> SessionView: + """Start an Edit session at edit.capability_check WITHOUT dispatching. + Mock 02-edit.txt:3 -- on open, show history + composer only; do not read + or lock the canvas. The graph read + impact analysis happen on the first + send_edit_goal action, not here. Unlike create_build_session, this takes + no goal and enqueues no advance -- the session simply rests, canvas + editable, until the user sends their change request.""" + fc = CopilotContext() + s = Session( + app_id=app_id, + tenant_id=actor.tenant_id, + owner_account_id=actor.account_id, + entry_mode=EntryMode.EDIT, + current_state=PcState.EDIT_CAPABILITY_CHECK, + ) + self._repo.create_session(s, fc, []) # empty conversation; assigns s.id, s.version = 1 + return self.get_session_view(s.id, actor) + def get_session_view(self, session_id: str, actor: Actor) -> SessionView: """Port of Go ``GetSessionView``.""" s, _fc = self._repo.get_session(session_id) # raises NotFoundError if absent diff --git a/api/tasks/workflow_copilot_advance_task.py b/api/tasks/workflow_copilot_advance_task.py index a7739a01b92..8fe4eb2b54b 100644 --- a/api/tasks/workflow_copilot_advance_task.py +++ b/api/tasks/workflow_copilot_advance_task.py @@ -27,6 +27,7 @@ from sqlalchemy.orm import sessionmaker from configs import dify_config from core.workflow_copilot.errors import ConflictError from core.workflow_copilot.handlers_build import build_registry +from core.workflow_copilot.handlers_edit import edit_registry from core.workflow_copilot.handlers_fix import fix_registry from core.workflow_copilot.models import Action, Actor, NodeEvent, Turn from core.workflow_copilot.runner import Env, Runner @@ -71,7 +72,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: actor = Actor(**actor_dict) env = Env(dify=dify, agent=agent, repo=repo, now=naive_utc_now, emit=emit, emit_canvas=emit_canvas) - runner = Runner(env, fix_registry() | build_registry()) + runner = Runner(env, fix_registry() | build_registry() | edit_registry()) runner.advance(session_id, Turn(action=Action(**action_dict), actor=actor)) # Project a SessionView (single source of truth for phase/run_status/ # actions) rather than recomputing those fields inline -- once Task 5 diff --git a/api/tests/unit_tests/controllers/console/test_workflow_copilot_routes.py b/api/tests/unit_tests/controllers/console/test_workflow_copilot_routes.py index 2d7b78b9fb4..067581da2b3 100644 --- a/api/tests/unit_tests/controllers/console/test_workflow_copilot_routes.py +++ b/api/tests/unit_tests/controllers/console/test_workflow_copilot_routes.py @@ -78,6 +78,22 @@ def test_create_build_scenario_calls_create_build_session(monkeypatch): svc.create_fix_session.assert_not_called() +def test_create_edit_scenario_calls_create_edit_session(monkeypatch): + svc = MagicMock() + svc.create_edit_session.return_value = _session_view() + monkeypatch.setattr(mod, "build_service", lambda: svc) + actor = _actor() + + body, status = mod._create({"scenario": "edit", "app_id": "a1"}, actor) + + assert status == 201 + svc.create_edit_session.assert_called_once() + called = svc.create_edit_session.call_args + assert called.kwargs["app_id"] == "a1" + svc.create_fix_session.assert_not_called() + svc.create_build_session.assert_not_called() + + def test_create_rejects_non_dict_body(): actor = _actor() svc = MagicMock() diff --git a/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py b/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py index c6a442a681c..4e7fe6f398a 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_advance_task.py @@ -249,3 +249,28 @@ def test_advance_session_resolves_build_handler_via_merged_registry( stored, _fc = repo.get_session(s.id) assert stored.current_state == PcState.BUILD_GOAL_ANALYSIS # build handler resolved, not a 500 assert (s.id, "tok-b1") in released + + +def _seed_edit_session_at_capability(repo: SqlCopilotRepository) -> Session: + s = Session( + app_id=APP_ID, + tenant_id=TENANT_ID, + owner_account_id=ACCOUNT_ID, + entry_mode=EntryMode.EDIT, + current_state=PcState.EDIT_CAPABILITY_CHECK, + ) + repo.create_session(s, CopilotContext(), [ConversationItem(kind="user", seq=0)]) + return s + + +def test_advance_session_resolves_edit_handler_via_merged_registry( + repo: SqlCopilotRepository, wired +) -> None: + _events, released = wired + s = _seed_edit_session_at_capability(repo) + + mod.advance_session(s.id, _act("send_edit_goal", 1, text="Tighten risk"), _ACTOR_DICT, "tok-e1") + + stored, _fc = repo.get_session(s.id) + assert stored.current_state == PcState.EDIT_IMPACT_ANALYSIS # edit handler resolved, not a 500 + assert (s.id, "tok-e1") in released 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 0dec87649dd..d83514bedfe 100644 --- a/api/tests/unit_tests/services/workflow_copilot/test_service.py +++ b/api/tests/unit_tests/services/workflow_copilot/test_service.py @@ -515,3 +515,82 @@ def test_build_waiting_state_actions_resolve_to_handled_kinds() -> None: resolve_action_kind(a.id) for a in actions if a.id not in service_module._CLIENT_ONLY_ACTIONS } assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})" + + +def _seed_edit_at(repo: SqlCopilotRepository, state: PcState) -> Session: + s = Session( + app_id=APP_ID, + tenant_id=TENANT_ID, + owner_account_id=ACCOUNT_ID, + entry_mode=EntryMode.EDIT, + current_state=state, + ) + repo.create_session(s, CopilotContext(goal_text="Tighten risk"), [ConversationItem(kind="user", seq=0)]) + return s + + +def test_create_edit_session_rests_at_capability_check_without_dispatch( + service: WorkflowCopilotService, enqueued: list[tuple] +) -> None: + view = service.create_edit_session(APP_ID, _actor()) + assert view.state == "edit.capability_check" + assert view.entry_mode == EntryMode.EDIT + assert view.version == 1 + assert view.run_status == "waiting_input" + assert view.canvas_read_only is False + assert view.conversation == [] # mock: show history + composer, nothing read yet + assert enqueued == [] # NO advance dispatched (unlike Build) + assert [a.id for a in view.actions] == ["send_edit_goal"] + + +def test_edit_apply_changes_actions_and_run_status(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None: + s = _seed_edit_at(repo, PcState.EDIT_APPLY_CHANGES) + view = service.get_session_view(s.id, _actor()) + assert view.run_status == "waiting_input" + assert view.phase == "modify" + assert [(a.id, a.kind) for a in view.actions] == [ + ("run_affected_tests", ActionKind.PRIMARY), + ("revert", ActionKind.DESTRUCTIVE), + ] + + +def test_edit_review_actions(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None: + s = _seed_edit_at(repo, PcState.EDIT_REVIEW) + view = service.get_session_view(s.id, _actor()) + assert [a.id for a in view.actions] == [ + "publish_workflow", + "keep_draft", + "continue_adjusting", + "view_changes", + "revert", + ] + + +def test_edit_publish_is_terminal_with_no_actions(service: WorkflowCopilotService, repo: SqlCopilotRepository) -> None: + s = _seed_edit_at(repo, PcState.EDIT_PUBLISH) + view = service.get_session_view(s.id, _actor()) + assert view.run_status == "complete" + assert view.actions == [] + + +def test_edit_waiting_state_actions_resolve_to_handled_kinds() -> None: + """Every non-client-only Edit action id must resolve (via + resolve_action_kind) to a kind its handler in handlers_edit.py branches on. + approve_plan->approve_repair, revert->undo, continue_adjusting/ + retry_after_revert->re_fix reuse the existing global map; the rest pass + through (send_edit_goal, submit_edit_rules, run_affected_tests, keep_draft, + publish_workflow).""" + handled: dict[PcState, set[str]] = { + PcState.EDIT_CAPABILITY_CHECK: {"send_edit_goal"}, + PcState.EDIT_IMPACT_ANALYSIS: {"submit_edit_rules"}, + PcState.EDIT_PLAN_APPROVAL: {"approve_repair"}, + PcState.EDIT_APPLY_CHANGES: {"run_affected_tests", "undo"}, + PcState.EDIT_REVIEW: {"publish_workflow", "keep_draft", "re_fix", "undo"}, + PcState.EDIT_REVERTED: {"re_fix"}, + } + for state, kinds in handled.items(): + actions = service_module._ACTIONS_FOR[state] + resolved = { + resolve_action_kind(a.id) for a in actions if a.id not in service_module._CLIENT_ONLY_ACTIONS + } + assert resolved <= kinds, f"{state}: resolved {resolved} not handled by its handler ({kinds})"