mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
164 lines
5.8 KiB
Python
164 lines
5.8 KiB
Python
"""The Workflow Copilot usecase: session lifecycle + dispatch.
|
|
|
|
Port of dify-enterprise/server/pkg/enterprise/biz/copilot/usecase.go.
|
|
|
|
Dependency-injected (``repo``, ``session_lock``, ``enqueue_fn`` are
|
|
constructor args) so it stays unit-testable without importing the real
|
|
Redis-backed ``session_lock`` module or Celery -- those are wired in by the
|
|
caller (P3b Task 4: the Flask controller + the Celery task's ``.delay``).
|
|
"""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Protocol
|
|
|
|
from core.workflow_copilot.errors import BusyError, ConflictError, NotFoundError
|
|
from core.workflow_copilot.models import (
|
|
Action,
|
|
Actor,
|
|
ChecklistError,
|
|
ConversationItem,
|
|
EntryMode,
|
|
FixContext,
|
|
Session,
|
|
)
|
|
from core.workflow_copilot.ports import Repository
|
|
from core.workflow_copilot.state import PcState, canvas_read_only, is_waiting, is_working
|
|
|
|
__all__ = ["SessionLock", "SessionView", "WorkflowCopilotService"]
|
|
|
|
|
|
@dataclass
|
|
class SessionView:
|
|
"""Read model returned by the usecase's session-facing methods."""
|
|
|
|
session_id: str
|
|
app_id: str
|
|
version: int
|
|
state: str # str(PcState), e.g. "fix.await_verify"
|
|
canvas_read_only: bool
|
|
run_status: str
|
|
interrupted: bool
|
|
conversation: list[ConversationItem]
|
|
|
|
|
|
class SessionLock(Protocol):
|
|
"""The per-session advance gate. Both the ``session_lock`` module and
|
|
the in-memory fake used in tests satisfy this structurally."""
|
|
|
|
def acquire(self, session_id: str) -> str | None: ...
|
|
|
|
def release(self, session_id: str, token: str) -> None: ...
|
|
|
|
def exists(self, session_id: str) -> bool: ...
|
|
|
|
|
|
def _run_status(state: PcState) -> str:
|
|
"""Port of Go ``runStatusFor``."""
|
|
if state == PcState.SUCCESS:
|
|
return "complete"
|
|
if state == PcState.FAILED:
|
|
return "failed"
|
|
if is_waiting(state):
|
|
return "waiting-input"
|
|
if is_working(state):
|
|
return "executing"
|
|
return "unknown"
|
|
|
|
|
|
class WorkflowCopilotService:
|
|
"""Session lifecycle (create/view/action/message) + dispatch.
|
|
|
|
Port of Go ``usecase.go``'s ``Usecase``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repo: Repository,
|
|
session_lock: SessionLock,
|
|
enqueue_fn: Callable[[str, Action, Actor, str], None],
|
|
) -> None:
|
|
self._repo = repo
|
|
self._session_lock = session_lock
|
|
self._enqueue_fn = enqueue_fn
|
|
|
|
def create_fix_session(
|
|
self,
|
|
app_id: str,
|
|
actor: Actor,
|
|
failed_run_id: str | None = None,
|
|
checklist_errors: list[ChecklistError] | None = None,
|
|
) -> SessionView:
|
|
"""Port of Go ``CreateFixSession``.
|
|
|
|
Checklist takes precedence when errors are present. Does NOT create
|
|
a failed ``CopilotRun`` row -- ``failed_run_id`` is trusted to
|
|
already name a recorded run; recording it is an upstream/P4
|
|
concern.
|
|
"""
|
|
if checklist_errors:
|
|
entry_mode, state = EntryMode.FIX_CHECKLIST, PcState.CHECKLIST_DIAGNOSE
|
|
fc = FixContext(source="checklist", checklist_errors=checklist_errors)
|
|
else:
|
|
entry_mode, state = EntryMode.FIX, PcState.FIX_DIAGNOSE
|
|
fc = FixContext(failed_run_id=failed_run_id or "", source="run")
|
|
|
|
s = Session(
|
|
app_id=app_id,
|
|
tenant_id=actor.tenant_id,
|
|
owner_account_id=actor.account_id,
|
|
entry_mode=entry_mode,
|
|
current_state=state,
|
|
)
|
|
items = [ConversationItem(kind="run-context", seq=0, payload={"run_id": failed_run_id or ""})]
|
|
self._repo.create_session(s, fc, items) # assigns s.id, s.version = 1
|
|
|
|
self.dispatch(s.id, Action(kind="request_fix", base_version=1), actor)
|
|
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
|
|
if s.owner_account_id != actor.account_id:
|
|
raise NotFoundError("session not found") # do not leak existence to non-owners
|
|
items = self._repo.list_conversation(session_id)
|
|
st = s.current_state
|
|
return SessionView(
|
|
session_id=s.id,
|
|
app_id=s.app_id,
|
|
version=s.version,
|
|
state=str(st),
|
|
canvas_read_only=canvas_read_only(st),
|
|
run_status=_run_status(st),
|
|
interrupted=is_working(st) and not self._session_lock.exists(session_id),
|
|
conversation=items,
|
|
)
|
|
|
|
def submit_action(self, session_id: str, actor: Actor, action: Action) -> SessionView:
|
|
"""Port of Go ``SubmitAction``."""
|
|
s, _fc = self._repo.get_session(session_id)
|
|
if s.owner_account_id != actor.account_id:
|
|
raise NotFoundError("session not found")
|
|
if action.base_version != s.version:
|
|
raise ConflictError(f"stale base_version {action.base_version} for session {session_id}")
|
|
self.dispatch(session_id, action, actor)
|
|
return self.get_session_view(session_id, actor)
|
|
|
|
def submit_message(self, session_id: str, actor: Actor, text: str, base_version: int) -> SessionView:
|
|
"""Port of Go ``SubmitMessage``."""
|
|
return self.submit_action(
|
|
session_id, actor, Action(kind="message", payload={"text": text}, base_version=base_version)
|
|
)
|
|
|
|
def dispatch(self, session_id: str, action: Action, actor: Actor) -> None:
|
|
"""Port of Go ``dispatch``: acquire-or-busy + enqueue, release on
|
|
enqueue failure."""
|
|
token = self._session_lock.acquire(session_id)
|
|
if token is None:
|
|
raise BusyError(f"session {session_id} is busy")
|
|
try:
|
|
self._enqueue_fn(session_id, action, actor, token)
|
|
except Exception:
|
|
self._session_lock.release(session_id, token)
|
|
raise
|