v2.16.1 (#808)
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
c2cb612ac1
commit
564a14365e
22 changed files with 952 additions and 252 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.16.0"
|
||||
__version__ = "2.16.1"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import os
|
|||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
from typing import Any, Protocol, cast, override
|
||||
from typing import Any, Literal, Protocol, cast, override
|
||||
from uuid import uuid4
|
||||
|
||||
from acp import (
|
||||
|
|
@ -21,6 +21,7 @@ from acp import (
|
|||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeResponse,
|
||||
run_agent,
|
||||
|
|
@ -148,6 +149,13 @@ from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
|
|||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.trusted_folders import (
|
||||
WorkspaceTrustDecision,
|
||||
WorkspaceTrustPrompt,
|
||||
apply_workspace_trust_decision,
|
||||
available_workspace_trust_decisions,
|
||||
maybe_build_workspace_trust_prompt,
|
||||
)
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
ApprovalCallback,
|
||||
|
|
@ -193,6 +201,8 @@ logger = logging.getLogger("vibe")
|
|||
|
||||
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
||||
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
|
||||
WORKSPACE_TRUST_CAPABILITY = "workspace-trust"
|
||||
TRUST_REQUEST_METHOD = "trust/request"
|
||||
|
||||
|
||||
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
|
||||
|
|
@ -232,6 +242,24 @@ class TelemetrySendNotification(BaseModel):
|
|||
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
|
||||
|
||||
|
||||
class WorkspaceTrustRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
cwd: str
|
||||
repo_root: str | None = Field(default=None, alias="repoRoot")
|
||||
detected_files: list[str] = Field(alias="detectedFiles")
|
||||
repo_detected_files: list[str] = Field(alias="repoDetectedFiles")
|
||||
available_decisions: list[WorkspaceTrustDecision] = Field(
|
||||
alias="availableDecisions"
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceTrustResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
decision: WorkspaceTrustDecision | Literal["cancelled"]
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
|
@ -407,6 +435,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
is True
|
||||
)
|
||||
|
||||
def _client_supports_workspace_trust(self) -> bool:
|
||||
return bool(
|
||||
self.client_capabilities
|
||||
and self.client_capabilities.field_meta
|
||||
and self.client_capabilities.field_meta.get(WORKSPACE_TRUST_CAPABILITY)
|
||||
is True
|
||||
)
|
||||
|
||||
@override
|
||||
async def initialize(
|
||||
self,
|
||||
|
|
@ -699,6 +735,55 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
return modes_state, modes_config, models_state, models_config
|
||||
|
||||
def _build_workspace_trust_request(
|
||||
self, prompt: WorkspaceTrustPrompt
|
||||
) -> WorkspaceTrustRequest:
|
||||
return WorkspaceTrustRequest(
|
||||
cwd=str(prompt.cwd.resolve()),
|
||||
repoRoot=str(prompt.repo_root.resolve())
|
||||
if prompt.offer_repo_trust and prompt.repo_root
|
||||
else None,
|
||||
detectedFiles=prompt.detected_files,
|
||||
repoDetectedFiles=prompt.repo_detected_files,
|
||||
availableDecisions=available_workspace_trust_decisions(
|
||||
prompt, include_session=True
|
||||
),
|
||||
)
|
||||
|
||||
async def _resolve_workspace_trust(self, cwd: Path) -> None:
|
||||
if not self._client_supports_workspace_trust():
|
||||
return
|
||||
|
||||
prompt = maybe_build_workspace_trust_prompt(cwd)
|
||||
if prompt is None:
|
||||
return
|
||||
|
||||
request = self._build_workspace_trust_request(prompt)
|
||||
|
||||
try:
|
||||
raw_response = await self.client.ext_method(
|
||||
TRUST_REQUEST_METHOD, request.model_dump(mode="json", by_alias=True)
|
||||
)
|
||||
except RequestError as exc:
|
||||
if exc.code == NotImplementedMethodError.code:
|
||||
return
|
||||
raise
|
||||
|
||||
try:
|
||||
response = WorkspaceTrustResponse.model_validate(raw_response)
|
||||
except ValidationError as exc:
|
||||
raise InvalidRequestError(
|
||||
f"Invalid ACP trust decision response: {exc}"
|
||||
) from exc
|
||||
|
||||
if response.decision == "cancelled":
|
||||
raise InvalidRequestError("Workspace trust prompt was cancelled.")
|
||||
|
||||
try:
|
||||
apply_workspace_trust_decision(prompt, response.decision)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestError(str(exc)) from exc
|
||||
|
||||
@override
|
||||
async def new_session(
|
||||
self,
|
||||
|
|
@ -709,6 +794,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> NewSessionResponse:
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
await self._resolve_workspace_trust(Path.cwd())
|
||||
|
||||
config = self._load_config()
|
||||
hook_config_result = load_hooks_from_fs(config)
|
||||
|
|
@ -973,6 +1059,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> LoadSessionResponse | None:
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
await self._resolve_workspace_trust(Path.cwd())
|
||||
|
||||
config = self._load_config()
|
||||
hook_config_result = load_hooks_from_fs(config)
|
||||
|
|
|
|||
|
|
@ -292,7 +292,12 @@ def _maybe_run_startup_update_prompt(
|
|||
)
|
||||
sys.exit(0)
|
||||
case UpdatePromptResult.UPDATE_FAILED:
|
||||
rprint("[red]✗ Vibe could not be updated automatically.[/]")
|
||||
rprint(
|
||||
"[yellow]Vibe could not update automatically.[/]\n"
|
||||
" Update manually with your package manager (for example "
|
||||
"[bold]uv tool upgrade mistral-vibe[/]), or keep using "
|
||||
f"the current version ({__version__}) for now."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@ from rich import print as rprint
|
|||
from vibe import __version__
|
||||
from vibe.core.config.harness_files import init_harness_files_manager
|
||||
from vibe.core.trusted_folders import (
|
||||
find_git_repo_ancestor,
|
||||
find_repo_trustable_files_for_cwd,
|
||||
find_trustable_files,
|
||||
apply_workspace_trust_decision,
|
||||
maybe_build_workspace_trust_prompt,
|
||||
trusted_folders_manager,
|
||||
)
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDecision,
|
||||
TrustDialogQuitException,
|
||||
ask_trust_folder,
|
||||
)
|
||||
|
|
@ -157,38 +155,18 @@ def parse_arguments() -> argparse.Namespace:
|
|||
|
||||
|
||||
def check_and_resolve_trusted_folder(cwd: Path) -> None:
|
||||
if cwd.resolve() == Path.home().resolve():
|
||||
prompt = maybe_build_workspace_trust_prompt(cwd)
|
||||
if prompt is None:
|
||||
return
|
||||
|
||||
if trusted_folders_manager.is_trusted(cwd) is True:
|
||||
return
|
||||
if trusted_folders_manager.is_explicitly_untrusted(cwd):
|
||||
return
|
||||
|
||||
repo_root = find_git_repo_ancestor(cwd)
|
||||
detected_files = find_trustable_files(cwd)
|
||||
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
|
||||
if not detected_files and not repo_detected_files:
|
||||
return
|
||||
|
||||
offer_repo_trust = (
|
||||
repo_root is not None
|
||||
and trusted_folders_manager.is_trusted(repo_root) is not True
|
||||
and not trusted_folders_manager.is_explicitly_untrusted(repo_root)
|
||||
)
|
||||
repo_explicitly_untrusted = (
|
||||
repo_root is not None
|
||||
and trusted_folders_manager.is_explicitly_untrusted(repo_root)
|
||||
)
|
||||
|
||||
try:
|
||||
decision = ask_trust_folder(
|
||||
cwd,
|
||||
repo_root,
|
||||
detected_files,
|
||||
repo_detected_files=repo_detected_files,
|
||||
offer_repo_trust=offer_repo_trust,
|
||||
repo_explicitly_untrusted=repo_explicitly_untrusted,
|
||||
prompt.cwd,
|
||||
prompt.repo_root,
|
||||
prompt.detected_files,
|
||||
repo_detected_files=prompt.repo_detected_files,
|
||||
offer_repo_trust=prompt.offer_repo_trust,
|
||||
repo_explicitly_untrusted=prompt.repo_explicitly_untrusted,
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||
sys.exit(0)
|
||||
|
|
@ -196,13 +174,8 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None:
|
|||
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
||||
return
|
||||
|
||||
match decision:
|
||||
case TrustDecision.TRUST_REPO if repo_root is not None:
|
||||
trusted_folders_manager.add_trusted(repo_root)
|
||||
case TrustDecision.TRUST_CWD:
|
||||
trusted_folders_manager.add_trusted(cwd)
|
||||
case TrustDecision.DECLINE:
|
||||
trusted_folders_manager.add_untrusted(cwd)
|
||||
if decision is not None:
|
||||
apply_workspace_trust_decision(prompt, decision)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import codecs
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
from contextlib import aclosing, suppress
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
|
|
@ -170,10 +170,12 @@ from vibe.core.paths import HISTORY_FILE
|
|||
from vibe.core.rewind import RewindError
|
||||
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
|
||||
from vibe.core.session.resume_sessions import (
|
||||
RemoteResumeResult,
|
||||
RemoteResumeSessions,
|
||||
ResumeSessionInfo,
|
||||
can_delete_resume_session_source,
|
||||
list_local_resume_sessions,
|
||||
list_remote_resume_sessions,
|
||||
session_latest_messages,
|
||||
short_session_id,
|
||||
)
|
||||
from vibe.core.session.saved_sessions import (
|
||||
|
|
@ -428,6 +430,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._bash_task: asyncio.Task | None = None
|
||||
self._queue = QueueController(self._build_queue_ports())
|
||||
self._remote_manager = RemoteSessionManager()
|
||||
self._remote_resume = RemoteResumeSessions(lambda: self.config)
|
||||
self._resume_merge_task: asyncio.Task[None] | None = None
|
||||
|
||||
self._loading_widget: LoadingWidget | None = None
|
||||
self._pending_approval: asyncio.Future | None = None
|
||||
|
|
@ -2216,29 +2220,71 @@ class VibeApp(App): # noqa: PLR0904
|
|||
UserCommandMessage(f'Session renamed to "{renamed_title}".')
|
||||
)
|
||||
|
||||
async def _show_session_picker(self, **kwargs: Any) -> None:
|
||||
cwd = str(Path.cwd())
|
||||
local_sessions = (
|
||||
list_local_resume_sessions(self.config, cwd)
|
||||
if self.config.session_logging.enabled
|
||||
else []
|
||||
def _build_picker(self, sessions: list[ResumeSessionInfo]) -> SessionPickerApp:
|
||||
sessions = sorted(sessions, key=lambda s: s.end_time or "", reverse=True)
|
||||
return SessionPickerApp(
|
||||
sessions=sessions,
|
||||
latest_messages=session_latest_messages(sessions, self.config),
|
||||
current_session_id=self.agent_loop.session_id,
|
||||
cwd=str(Path.cwd()),
|
||||
)
|
||||
|
||||
async def _merge_remote_into_picker(
|
||||
self, picker: SessionPickerApp, remote_task: asyncio.Task[RemoteResumeResult]
|
||||
) -> None:
|
||||
remote_sessions, remote_error = await remote_task
|
||||
if not picker.is_mounted:
|
||||
return
|
||||
if remote_error is not None:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
|
||||
)
|
||||
if remote_sessions:
|
||||
picker.add_sessions(
|
||||
remote_sessions, session_latest_messages(remote_sessions, self.config)
|
||||
)
|
||||
|
||||
async def _cancel_resume_merge(self) -> None:
|
||||
"""Cancel the task that fetch and merges remote sessions into the picker, if it exists."""
|
||||
if self._resume_merge_task is not None and not self._resume_merge_task.done():
|
||||
self._resume_merge_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._resume_merge_task
|
||||
self._resume_merge_task = None
|
||||
|
||||
async def _close_remote_resume(self) -> None:
|
||||
"""Close the remote resume connection and cancel any ongoing merge task.
|
||||
Used to gracefully close the connection when the app is exiting
|
||||
"""
|
||||
await self._cancel_resume_merge()
|
||||
await self._remote_resume.aclose()
|
||||
|
||||
async def _show_session_picker(self, **kwargs: Any) -> None:
|
||||
await self._cancel_resume_merge()
|
||||
remote_list_timeout = max(float(self.config.api_timeout), 10.0)
|
||||
remote_error: str | None = None
|
||||
remote_task = self._remote_resume.start(remote_list_timeout)
|
||||
|
||||
# If there are no local sessions, show the remote picker directly
|
||||
if not self.config.session_logging.enabled or not (
|
||||
local_sessions := list_local_resume_sessions(self.config, str(Path.cwd()))
|
||||
):
|
||||
await self._show_session_picker_remote_only(remote_task)
|
||||
return
|
||||
|
||||
picker = self._build_picker(local_sessions)
|
||||
await self._switch_from_input(picker)
|
||||
self._resume_merge_task = asyncio.create_task(
|
||||
self._merge_remote_into_picker(picker, remote_task)
|
||||
)
|
||||
|
||||
async def _show_session_picker_remote_only(
|
||||
self, remote_task: asyncio.Task[RemoteResumeResult]
|
||||
) -> None:
|
||||
await self._ensure_loading_widget("Loading sessions")
|
||||
try:
|
||||
remote_sessions = await asyncio.wait_for(
|
||||
list_remote_resume_sessions(self.config), timeout=remote_list_timeout
|
||||
)
|
||||
except TimeoutError:
|
||||
remote_sessions = []
|
||||
remote_error = (
|
||||
"Timed out while listing remote sessions "
|
||||
f"after {remote_list_timeout:.0f}s."
|
||||
)
|
||||
except Exception as e:
|
||||
remote_sessions = []
|
||||
remote_error = f"Failed to list remote sessions: {e}"
|
||||
remote_sessions, remote_error = await remote_task
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
await self._remove_loading_widget()
|
||||
|
||||
|
|
@ -2247,37 +2293,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
|
||||
)
|
||||
|
||||
raw_sessions = [*local_sessions, *remote_sessions]
|
||||
|
||||
if not raw_sessions:
|
||||
if not remote_sessions:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No sessions found for this directory.")
|
||||
)
|
||||
return
|
||||
|
||||
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
|
||||
|
||||
latest_messages = {
|
||||
s.option_id: s.title
|
||||
or SessionLoader.get_first_user_message(
|
||||
s.session_id, self.config.session_logging
|
||||
)
|
||||
for s in sessions
|
||||
if s.source == "local"
|
||||
}
|
||||
for session in sessions:
|
||||
if session.source == "remote":
|
||||
latest_messages[session.option_id] = (
|
||||
f"{session.title or 'Remote workflow'} ({(session.status or 'RUNNING').lower()})"
|
||||
)
|
||||
|
||||
picker = SessionPickerApp(
|
||||
sessions=sessions,
|
||||
latest_messages=latest_messages,
|
||||
current_session_id=self.agent_loop.session_id,
|
||||
cwd=cwd,
|
||||
)
|
||||
await self._switch_from_input(picker)
|
||||
await self._switch_from_input(self._build_picker(remote_sessions))
|
||||
|
||||
async def on_session_picker_app_session_selected(
|
||||
self, event: SessionPickerApp.SessionSelected
|
||||
|
|
@ -2686,6 +2708,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._log_reader.shutdown()
|
||||
await self._voice_manager.close()
|
||||
await self._narrator_manager.close()
|
||||
await self._close_remote_resume()
|
||||
await self.agent_loop.aclose()
|
||||
try:
|
||||
await self.agent_loop.telemetry_client.aclose()
|
||||
|
|
@ -3437,6 +3460,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._log_reader.shutdown()
|
||||
self._narrator_manager.cancel()
|
||||
await self._close_remote_resume()
|
||||
await self.agent_loop.aclose()
|
||||
try:
|
||||
await self.agent_loop.telemetry_client.aclose()
|
||||
|
|
|
|||
|
|
@ -168,6 +168,18 @@ class SessionPickerApp(Container):
|
|||
def _normal_option_text(self, session: ResumeSessionInfo) -> Text:
|
||||
return _build_option_text(session, self._session_message(session))
|
||||
|
||||
def _option_text(self, session: ResumeSessionInfo) -> Text:
|
||||
state = self._delete_state
|
||||
if state is None or state.option_id != session.option_id:
|
||||
return self._normal_option_text(session)
|
||||
match state.kind:
|
||||
case "confirmation":
|
||||
return self._delete_confirmation_option_text(session)
|
||||
case "feedback":
|
||||
return self._delete_feedback_option_text(session)
|
||||
case "pending":
|
||||
return self._delete_pending_option_text(session)
|
||||
|
||||
def _delete_confirmation_option_text(self, session: ResumeSessionInfo) -> Text:
|
||||
text = _build_option_text(session, "")
|
||||
text.append("Press D again to delete")
|
||||
|
|
@ -239,6 +251,41 @@ class SessionPickerApp(Container):
|
|||
self._option_list().remove_option(option_id)
|
||||
return True
|
||||
|
||||
def add_sessions(
|
||||
self, sessions: list[ResumeSessionInfo], latest_messages: dict[str, str]
|
||||
) -> None:
|
||||
existing = {s.option_id for s in self._sessions}
|
||||
new_sessions = [s for s in sessions if s.option_id not in existing]
|
||||
if not new_sessions:
|
||||
return
|
||||
|
||||
self._sessions = sorted(
|
||||
[*self._sessions, *new_sessions],
|
||||
key=lambda s: s.end_time or "",
|
||||
reverse=True,
|
||||
)
|
||||
self._latest_messages.update(latest_messages)
|
||||
|
||||
option_list = self._option_list()
|
||||
highlighted = self._highlighted_option_id()
|
||||
option_list.clear_options()
|
||||
option_list.add_options([
|
||||
Option(self._option_text(session), id=session.option_id)
|
||||
for session in self._sessions
|
||||
])
|
||||
self._refresh_header()
|
||||
if highlighted is None:
|
||||
return
|
||||
for index, session in enumerate(self._sessions):
|
||||
if session.option_id == highlighted:
|
||||
option_list.highlighted = index
|
||||
return
|
||||
|
||||
def _refresh_header(self) -> None:
|
||||
has_remote = any(session.source == "remote" for session in self._sessions)
|
||||
header = self.query_one(".sessionpicker-header", NoMarkupStatic)
|
||||
header.update(_build_header_text(self._cwd, has_remote))
|
||||
|
||||
def clear_pending_delete(self, option_id: str) -> bool:
|
||||
if not self._delete_state_matches(option_id, "pending"):
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ class WorkflowsClient:
|
|||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_client and self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class RemoteEventsSource:
|
|||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.__aexit__(None, None, None)
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def attach(self) -> AsyncGenerator[BaseEvent, None]:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Sequence
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Literal, NamedTuple, Protocol
|
||||
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.nuage.client import WorkflowsClient
|
||||
from vibe.core.nuage.workflow import WorkflowExecutionStatus
|
||||
from vibe.core.nuage.workflow import (
|
||||
WorkflowExecutionListResponse,
|
||||
WorkflowExecutionStatus,
|
||||
)
|
||||
from vibe.core.session.session_id import shorten_session_id
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
||||
|
|
@ -28,6 +34,21 @@ _ACTIVE_STATUSES = [
|
|||
]
|
||||
|
||||
|
||||
class RemoteWorkflowRunsClient(Protocol):
|
||||
async def get_workflow_runs(
|
||||
self,
|
||||
workflow_identifier: str | None = None,
|
||||
page_size: int = 50,
|
||||
next_page_token: str | None = None,
|
||||
status: Sequence[WorkflowExecutionStatus] | None = None,
|
||||
user_id: str = "current",
|
||||
) -> WorkflowExecutionListResponse: ...
|
||||
|
||||
|
||||
class RemoteResumeClient(RemoteWorkflowRunsClient, Protocol):
|
||||
async def aclose(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeSessionInfo:
|
||||
session_id: str
|
||||
|
|
@ -61,21 +82,12 @@ def list_local_resume_sessions(
|
|||
]
|
||||
|
||||
|
||||
async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]:
|
||||
if not config.vibe_code_enabled or not config.vibe_code_api_key:
|
||||
logger.debug("Remote resume listing skipped: missing Vibe Code configuration")
|
||||
return []
|
||||
|
||||
async with WorkflowsClient(
|
||||
base_url=config.vibe_code_base_url,
|
||||
api_key=config.vibe_code_api_key,
|
||||
timeout=config.api_timeout,
|
||||
) as client:
|
||||
response = await client.get_workflow_runs(
|
||||
workflow_identifier=config.vibe_code_workflow_id,
|
||||
page_size=50,
|
||||
status=_ACTIVE_STATUSES,
|
||||
)
|
||||
async def list_remote_resume_sessions(
|
||||
client: RemoteWorkflowRunsClient, workflow_id: str
|
||||
) -> list[ResumeSessionInfo]:
|
||||
response = await client.get_workflow_runs(
|
||||
workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES
|
||||
)
|
||||
|
||||
seen: dict[str, ResumeSessionInfo] = {}
|
||||
latest_start: dict[str, datetime] = {}
|
||||
|
|
@ -101,3 +113,111 @@ async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionI
|
|||
|
||||
logger.debug("Remote resume listing filtered sessions: %d", len(sessions))
|
||||
return sessions
|
||||
|
||||
|
||||
def session_latest_messages(
|
||||
sessions: list[ResumeSessionInfo], config: VibeConfig
|
||||
) -> dict[str, str]:
|
||||
messages: dict[str, str] = {}
|
||||
for session in sessions:
|
||||
if session.source == "remote":
|
||||
status = (session.status or "RUNNING").lower()
|
||||
messages[session.option_id] = (
|
||||
f"{session.title or 'Remote workflow'} ({status})"
|
||||
)
|
||||
continue
|
||||
messages[session.option_id] = (
|
||||
session.title
|
||||
or SessionLoader.get_first_user_message(
|
||||
session.session_id, config.session_logging
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
class RemoteResumeResult(NamedTuple):
|
||||
sessions: list[ResumeSessionInfo]
|
||||
error: str | None
|
||||
|
||||
|
||||
def _default_remote_resume_client(config: VibeConfig) -> RemoteResumeClient:
|
||||
return WorkflowsClient(
|
||||
base_url=config.vibe_code_base_url,
|
||||
api_key=config.vibe_code_api_key,
|
||||
timeout=config.api_timeout,
|
||||
)
|
||||
|
||||
|
||||
class RemoteResumeSessions:
|
||||
def __init__(
|
||||
self,
|
||||
get_config: Callable[[], VibeConfig],
|
||||
client_factory: Callable[
|
||||
[VibeConfig], RemoteResumeClient
|
||||
] = _default_remote_resume_client,
|
||||
) -> None:
|
||||
self._get_config = get_config
|
||||
self._client_factory = client_factory
|
||||
self._client: RemoteResumeClient | None = None
|
||||
self._client_settings: tuple[str, str, float] | None = None
|
||||
self._fetch_task: asyncio.Task[RemoteResumeResult] | None = None
|
||||
|
||||
async def _reusable_client(self, config: VibeConfig) -> RemoteResumeClient:
|
||||
settings = (
|
||||
config.vibe_code_base_url,
|
||||
config.vibe_code_api_key,
|
||||
config.api_timeout,
|
||||
)
|
||||
if self._client is not None and self._client_settings == settings:
|
||||
return self._client
|
||||
if self._client is not None:
|
||||
await self._close_client()
|
||||
self._client = self._client_factory(config)
|
||||
self._client_settings = settings
|
||||
return self._client
|
||||
|
||||
def start(self, timeout: float) -> asyncio.Task[RemoteResumeResult]:
|
||||
if self._fetch_task is not None and not self._fetch_task.done():
|
||||
self._fetch_task.cancel()
|
||||
self._fetch_task = asyncio.create_task(self.fetch(timeout))
|
||||
return self._fetch_task
|
||||
|
||||
async def fetch(self, timeout: float) -> RemoteResumeResult:
|
||||
config = self._get_config()
|
||||
if not config.vibe_code_enabled or not config.vibe_code_api_key:
|
||||
logger.debug(
|
||||
"Remote resume listing skipped: missing Vibe Code configuration"
|
||||
)
|
||||
return RemoteResumeResult([], None)
|
||||
try:
|
||||
client = await self._reusable_client(config)
|
||||
sessions = await asyncio.wait_for(
|
||||
list_remote_resume_sessions(client, config.vibe_code_workflow_id),
|
||||
timeout=timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
return RemoteResumeResult(
|
||||
[], f"Timed out while listing remote sessions after {timeout:.0f}s."
|
||||
)
|
||||
except Exception as e:
|
||||
return RemoteResumeResult([], f"Failed to list remote sessions: {e}")
|
||||
return RemoteResumeResult(sessions, None)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._fetch_task is not None and not self._fetch_task.done():
|
||||
self._fetch_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._fetch_task
|
||||
self._fetch_task = None
|
||||
await self._close_client()
|
||||
|
||||
async def _close_client(self) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
await self._client.aclose()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to close resume workflows client", exc_info=exc)
|
||||
finally:
|
||||
self._client = None
|
||||
self._client_settings = None
|
||||
|
|
|
|||
|
|
@ -26,39 +26,57 @@ class SessionInfo(TypedDict):
|
|||
|
||||
class SessionLoader:
|
||||
@staticmethod
|
||||
def _is_valid_session( # noqa: PLR0911
|
||||
def _parse_message_lines(text: str) -> list[dict[str, Any]] | None:
|
||||
lines = text.split("\n")
|
||||
if lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for line in lines:
|
||||
message = json.loads(line)
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
messages.append(message)
|
||||
return messages or None
|
||||
|
||||
@staticmethod
|
||||
def _read_validated_session(
|
||||
session_dir: Path, working_directory: Path | None = None
|
||||
) -> bool:
|
||||
"""Check if a session directory contains valid metadata and messages."""
|
||||
) -> dict[str, Any] | None:
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
messages_path = session_dir / MESSAGES_FILENAME
|
||||
|
||||
if not metadata_path.is_file() or not messages_path.is_file():
|
||||
return False
|
||||
return None
|
||||
|
||||
try:
|
||||
metadata = json.loads(read_safe(metadata_path).text)
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
return None
|
||||
if working_directory is not None:
|
||||
session_working_directory = (metadata.get("environment") or {}).get(
|
||||
"working_directory"
|
||||
)
|
||||
if session_working_directory != str(working_directory):
|
||||
return False
|
||||
return None
|
||||
|
||||
has_messages = False
|
||||
for line in read_safe(messages_path).text.splitlines():
|
||||
has_messages = True
|
||||
message = json.loads(line)
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if not has_messages:
|
||||
return False
|
||||
messages = SessionLoader._parse_message_lines(read_safe(messages_path).text)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
return None
|
||||
|
||||
return True
|
||||
if messages is None:
|
||||
return None
|
||||
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_session(
|
||||
session_dir: Path, working_directory: Path | None = None
|
||||
) -> bool:
|
||||
return (
|
||||
SessionLoader._read_validated_session(session_dir, working_directory)
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def latest_session(
|
||||
|
|
@ -158,13 +176,8 @@ class SessionLoader:
|
|||
|
||||
sessions: list[SessionInfo] = []
|
||||
for session_dir in session_dirs:
|
||||
if not SessionLoader._is_valid_session(session_dir):
|
||||
continue
|
||||
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
try:
|
||||
metadata = json.loads(read_safe(metadata_path).text)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
metadata = SessionLoader._read_validated_session(session_dir)
|
||||
if metadata is None:
|
||||
continue
|
||||
|
||||
session_id = metadata.get("session_id")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
|
|
@ -13,6 +15,23 @@ from vibe.core.paths import (
|
|||
)
|
||||
|
||||
|
||||
class WorkspaceTrustDecision(StrEnum):
|
||||
TRUST_REPO = "trust_repo"
|
||||
TRUST_CWD = "trust_cwd"
|
||||
TRUST_SESSION = "trust_session"
|
||||
DECLINE = "decline"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceTrustPrompt:
|
||||
cwd: Path
|
||||
repo_root: Path | None
|
||||
detected_files: list[str]
|
||||
repo_detected_files: list[str]
|
||||
offer_repo_trust: bool
|
||||
repo_explicitly_untrusted: bool
|
||||
|
||||
|
||||
def has_agents_md_file(path: Path) -> bool:
|
||||
agents_md = path / AGENTS_MD_FILENAME
|
||||
try:
|
||||
|
|
@ -91,6 +110,73 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list
|
|||
return sorted(found)
|
||||
|
||||
|
||||
def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None:
|
||||
resolved_cwd = cwd.resolve()
|
||||
if resolved_cwd == Path.home().resolve():
|
||||
return None
|
||||
|
||||
if trusted_folders_manager.is_trusted(cwd) is True:
|
||||
return None
|
||||
if trusted_folders_manager.is_explicitly_untrusted(cwd):
|
||||
return None
|
||||
|
||||
repo_root = find_git_repo_ancestor(cwd)
|
||||
detected_files = find_trustable_files(cwd)
|
||||
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
|
||||
if not detected_files and not repo_detected_files:
|
||||
return None
|
||||
|
||||
resolved_repo_root = repo_root.resolve() if repo_root else None
|
||||
offer_repo_trust = (
|
||||
resolved_repo_root is not None
|
||||
and resolved_repo_root in resolved_cwd.parents
|
||||
and trusted_folders_manager.is_trusted(resolved_repo_root) is not True
|
||||
and not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
|
||||
)
|
||||
repo_explicitly_untrusted = (
|
||||
resolved_repo_root is not None
|
||||
and trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
|
||||
)
|
||||
|
||||
return WorkspaceTrustPrompt(
|
||||
cwd=cwd,
|
||||
repo_root=resolved_repo_root,
|
||||
detected_files=detected_files,
|
||||
repo_detected_files=repo_detected_files,
|
||||
offer_repo_trust=offer_repo_trust,
|
||||
repo_explicitly_untrusted=repo_explicitly_untrusted,
|
||||
)
|
||||
|
||||
|
||||
def available_workspace_trust_decisions(
|
||||
prompt: WorkspaceTrustPrompt, *, include_session: bool = False
|
||||
) -> list[WorkspaceTrustDecision]:
|
||||
decisions = [WorkspaceTrustDecision.TRUST_CWD, WorkspaceTrustDecision.DECLINE]
|
||||
if include_session:
|
||||
decisions.insert(1, WorkspaceTrustDecision.TRUST_SESSION)
|
||||
if prompt.offer_repo_trust:
|
||||
decisions.insert(0, WorkspaceTrustDecision.TRUST_REPO)
|
||||
return decisions
|
||||
|
||||
|
||||
def apply_workspace_trust_decision(
|
||||
prompt: WorkspaceTrustPrompt, decision: WorkspaceTrustDecision
|
||||
) -> None:
|
||||
match decision:
|
||||
case WorkspaceTrustDecision.TRUST_REPO if (
|
||||
prompt.offer_repo_trust and prompt.repo_root is not None
|
||||
):
|
||||
trusted_folders_manager.add_trusted(prompt.repo_root)
|
||||
case WorkspaceTrustDecision.TRUST_CWD:
|
||||
trusted_folders_manager.add_trusted(prompt.cwd)
|
||||
case WorkspaceTrustDecision.TRUST_SESSION:
|
||||
trusted_folders_manager.trust_for_session(prompt.cwd)
|
||||
case WorkspaceTrustDecision.DECLINE:
|
||||
trusted_folders_manager.add_untrusted(prompt.cwd)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported trust decision: {decision}")
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
def __init__(self) -> None:
|
||||
self._file_path = TRUSTED_FOLDERS_FILE.path
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ def _get_candidate_encodings(
|
|||
|
||||
def normalize_newlines(text: str) -> tuple[str, str]:
|
||||
r"""Return ``text`` with ``\n`` newlines and the detected original style."""
|
||||
if "\r" not in text:
|
||||
newline = "\n" if "\n" in text else os.linesep
|
||||
return text, newline
|
||||
newline = _detect_newline(text)
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n"), newline
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
|
|
@ -19,18 +18,14 @@ from textual.widgets import Static
|
|||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.paths import TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.trusted_folders import WorkspaceTrustDecision
|
||||
|
||||
|
||||
class TrustDialogQuitException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TrustDecision(Enum):
|
||||
"""User's choice from the trust dialog."""
|
||||
|
||||
TRUST_REPO = "trust_repo"
|
||||
TRUST_CWD = "trust_cwd"
|
||||
DECLINE = "decline"
|
||||
TrustDecision = WorkspaceTrustDecision
|
||||
|
||||
|
||||
class TrustFolderDialog(CenterMiddle):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue